diff --git a/Cargo.lock b/Cargo.lock index 64e65fc8c..a1dd7d5ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2331,7 +2331,7 @@ dependencies = [ [[package]] name = "pi-ast" -version = "15.10.4" +version = "15.10.5" dependencies = [ "anyhow", "ast-grep-core", @@ -2399,7 +2399,7 @@ dependencies = [ [[package]] name = "pi-iso" -version = "15.10.4" +version = "15.10.5" dependencies = [ "async-trait", "libc", @@ -2411,7 +2411,7 @@ dependencies = [ [[package]] name = "pi-natives" -version = "15.10.4" +version = "15.10.5" dependencies = [ "anyhow", "arboard", @@ -2457,7 +2457,7 @@ dependencies = [ [[package]] name = "pi-shell" -version = "15.10.4" +version = "15.10.5" dependencies = [ "anyhow", "brush-builtins", diff --git a/Cargo.toml b/Cargo.toml index 4e6a0e6b0..8c7f16d9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ exclude = ["crates/brush-core-vendored", "crates/brush-builtins-vendored"] resolver = "3" [workspace.package] -version = "15.10.4" +version = "15.10.5" edition = "2024" license = "MIT" authors = ["Can Boluk"] diff --git a/bun.lock b/bun.lock index f266d5a6e..695510302 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,7 @@ }, "packages/agent": { "name": "@oh-my-pi/pi-agent-core", - "version": "15.10.4", + "version": "15.10.5", "dependencies": { "@oh-my-pi/pi-ai": "catalog:", "@oh-my-pi/pi-natives": "catalog:", @@ -30,7 +30,7 @@ }, "packages/ai": { "name": "@oh-my-pi/pi-ai", - "version": "15.10.4", + "version": "15.10.5", "dependencies": { "@bufbuild/protobuf": "catalog:", "@oh-my-pi/pi-utils": "catalog:", @@ -44,7 +44,7 @@ }, "packages/coding-agent": { "name": "@oh-my-pi/pi-coding-agent", - "version": "15.10.4", + "version": "15.10.5", "bin": { "omp": "src/cli.ts", }, @@ -90,7 +90,7 @@ }, "packages/hashline": { "name": "@oh-my-pi/hashline", - "version": "15.10.4", + "version": "15.10.5", "dependencies": { "diff": "catalog:", "lru-cache": "catalog:", @@ -101,7 +101,7 @@ }, "packages/mnemopi": { "name": "@oh-my-pi/pi-mnemopi", - "version": "15.10.4", + "version": "15.10.5", "bin": { "mnemopi": "src/cli.ts", }, @@ -118,7 +118,7 @@ }, "packages/natives": { "name": "@oh-my-pi/pi-natives", - "version": "15.10.4", + "version": "15.10.5", "devDependencies": { "@napi-rs/cli": "catalog:", "@types/bun": "catalog:", @@ -126,7 +126,7 @@ }, "packages/stats": { "name": "@oh-my-pi/omp-stats", - "version": "15.10.4", + "version": "15.10.5", "bin": { "omp-stats": "./src/index.ts", }, @@ -151,7 +151,7 @@ }, "packages/swarm-extension": { "name": "@oh-my-pi/swarm-extension", - "version": "15.10.4", + "version": "15.10.5", "bin": { "omp-swarm": "src/cli.ts", }, @@ -167,7 +167,7 @@ }, "packages/tui": { "name": "@oh-my-pi/pi-tui", - "version": "15.10.4", + "version": "15.10.5", "dependencies": { "@oh-my-pi/pi-natives": "catalog:", "@oh-my-pi/pi-utils": "catalog:", @@ -208,7 +208,7 @@ }, "packages/utils": { "name": "@oh-my-pi/pi-utils", - "version": "15.10.4", + "version": "15.10.5", "dependencies": { "@oh-my-pi/pi-natives": "catalog:", "beautiful-mermaid": "catalog:", @@ -248,15 +248,15 @@ "@huggingface/transformers": "^4.2.0", "@mozilla/readability": "^0.6.0", "@napi-rs/cli": "3.7.0", - "@oh-my-pi/hashline": "15.10.4", - "@oh-my-pi/omp-stats": "15.10.4", - "@oh-my-pi/pi-agent-core": "15.10.4", - "@oh-my-pi/pi-ai": "15.10.4", - "@oh-my-pi/pi-coding-agent": "15.10.4", - "@oh-my-pi/pi-mnemopi": "15.10.4", - "@oh-my-pi/pi-natives": "15.10.4", - "@oh-my-pi/pi-tui": "15.10.4", - "@oh-my-pi/pi-utils": "15.10.4", + "@oh-my-pi/hashline": "15.10.5", + "@oh-my-pi/omp-stats": "15.10.5", + "@oh-my-pi/pi-agent-core": "15.10.5", + "@oh-my-pi/pi-ai": "15.10.5", + "@oh-my-pi/pi-coding-agent": "15.10.5", + "@oh-my-pi/pi-mnemopi": "15.10.5", + "@oh-my-pi/pi-natives": "15.10.5", + "@oh-my-pi/pi-tui": "15.10.5", + "@oh-my-pi/pi-utils": "15.10.5", "@opentelemetry/api": "^1.9.1", "@opentelemetry/context-async-hooks": "^2.7.1", "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0", @@ -391,7 +391,7 @@ "@huggingface/blake3-jit": ["@huggingface/blake3-jit@0.0.2", "", {}, "sha512-Bq7B5qabyjrJfhBsl85Jd2QBtf+HzRD7h7A9GfN2lzrrsABhOa5evVPgzoCTxR7Ub0QFj7YDK1YkYRWBU25+2w=="], - "@huggingface/hub": ["@huggingface/hub@2.13.0", "", { "dependencies": { "@huggingface/tasks": "^0.21.1", "@huggingface/xetchunk-wasm": "^0.0.6" }, "optionalDependencies": { "cli-progress": "^3.12.0" }, "bin": { "hfjs": "dist/cli.js" } }, "sha512-IAoqdpTV9HeMyooxKVvVGWirOJ+S2IAKnU2FSQSMz62ehPTKzxADAATy1fwAlYYQdHCV119GHFf4p9+ECQ+I5g=="], + "@huggingface/hub": ["@huggingface/hub@2.13.1", "", { "dependencies": { "@huggingface/tasks": "^0.21.8", "@huggingface/xetchunk-wasm": "^0.0.6" }, "optionalDependencies": { "cli-progress": "^3.12.0" }, "bin": { "hfjs": "dist/cli.js" } }, "sha512-2eMXRsSvGSzayXAYKO27PoZdfREY20eNhSU4HRTFwBmKb0L2lAUO9tldJOIol1YtHahgI/cgcpJgCrZArHQtsw=="], "@huggingface/jinja": ["@huggingface/jinja@0.5.9", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="], @@ -833,7 +833,7 @@ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.33", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.34", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw=="], "beautiful-mermaid": ["beautiful-mermaid@1.1.3", "", { "dependencies": { "elkjs": "^0.11.0", "entities": "^7.0.1" } }, "sha512-TItrtrAyHp1vwFfFVYauWGrquouk/6SS21Aq3RsxindSYZODcN4xYrPZD6BiZRU+o5mKJzDPz9MUSMvELdylyg=="], @@ -937,7 +937,7 @@ "enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="], - "enhanced-resolve": ["enhanced-resolve@5.22.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag=="], + "enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="], "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], diff --git a/crates/brush-builtins-vendored/Cargo.lock b/crates/brush-builtins-vendored/Cargo.lock index 4c7c70e9f..b40f9d459 100644 --- a/crates/brush-builtins-vendored/Cargo.lock +++ b/crates/brush-builtins-vendored/Cargo.lock @@ -80,7 +80,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -91,7 +91,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -239,8 +239,6 @@ dependencies = [ [[package]] name = "brush-core" version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "967a82f3b1bf090db686d5fb86424b19725073828532a42f960d5a13bff14ecb" dependencies = [ "async-recursion", "async-trait", @@ -269,10 +267,12 @@ dependencies = [ "terminfo", "thiserror", "tokio", + "tokio-util", "tracing", "uuid", "uzers", "whoami", + "windows-sys 0.59.0", ] [[package]] @@ -480,7 +480,7 @@ checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" dependencies = [ "encode_unicode", "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -647,7 +647,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1197,7 +1197,7 @@ checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1603,7 +1603,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1737,7 +1737,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1802,7 +1802,7 @@ dependencies = [ "getrandom", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1812,7 +1812,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1871,7 +1871,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1885,6 +1885,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "tracing" version = "0.1.44" @@ -2237,7 +2250,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca229916c5ee38c2f2bc1e9d8f04df975b4bd93f9955dc69fabb5d91270045c9" dependencies = [ "windows-core 0.51.1", - "windows-targets", + "windows-targets 0.48.5", ] [[package]] @@ -2246,7 +2259,7 @@ version = "0.51.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64" dependencies = [ - "windows-targets", + "windows-targets 0.48.5", ] [[package]] @@ -2308,6 +2321,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2323,13 +2345,29 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -2338,42 +2376,90 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/crates/brush-builtins-vendored/src/cd.rs b/crates/brush-builtins-vendored/src/cd.rs index bb193724c..b551fb940 100644 --- a/crates/brush-builtins-vendored/src/cd.rs +++ b/crates/brush-builtins-vendored/src/cd.rs @@ -1,6 +1,6 @@ use std::{io::Write, path::PathBuf}; -use brush_core::{ExecutionResult, builtins, error}; +use brush_core::{ExecutionResult, builtins}; use clap::Parser; /// Change the current shell working directory. @@ -19,11 +19,6 @@ pub(crate) struct CdCommand { #[arg(short = 'e')] exit_on_failed_cwd_resolution: bool, - /// Show file with extended attributes as a dir with extended - /// attributes. - #[arg(short = '@')] - file_with_xattr_as_dir: bool, - /// By default it is the value of the HOME shell variable. If `TARGET_DIR` is /// "-", it is converted to $OLDPWD. target_dir: Option, @@ -36,11 +31,6 @@ impl builtins::Command for CdCommand { &self, context: brush_core::ExecutionContext<'_, SE>, ) -> Result { - // TODO(cd): implement 'cd -@' - if self.file_with_xattr_as_dir { - return error::unimp("cd -@"); - } - let mut should_print = false; let mut target_dir = if let Some(target_dir) = &self.target_dir { // `cd -', equivalent to `cd $OLDPWD' @@ -72,11 +62,9 @@ impl builtins::Command for CdCommand { .options() .do_not_resolve_symlinks_when_changing_dir { - // -e is only relevant in physical mode. - if self.exit_on_failed_cwd_resolution { - return error::unimp("cd -e"); - } - + // -e is only relevant in physical mode. `canonicalize()` is the + // cwd-resolution step this implementation supports; failures already + // propagate as a non-zero result before updating PWD/OLDPWD. target_dir = context.shell.absolute_path(target_dir).canonicalize()?; } diff --git a/crates/brush-builtins-vendored/src/colon.rs b/crates/brush-builtins-vendored/src/colon.rs index bcc2289ce..a4cf1f310 100644 --- a/crates/brush-builtins-vendored/src/colon.rs +++ b/crates/brush-builtins-vendored/src/colon.rs @@ -1,4 +1,4 @@ -use brush_core::{ExecutionResult, builtins, error}; +use brush_core::{ExecutionResult, builtins}; /// No-op command. pub(crate) struct ColonCommand {} @@ -13,7 +13,10 @@ impl builtins::SimpleCommand for ColonCommand { builtins::ContentType::DetailedHelp => Ok("Null command; always returns success.".into()), builtins::ContentType::ShortUsage => Ok(":: :".into()), builtins::ContentType::ShortDescription => Ok(": - Null command".into()), - builtins::ContentType::ManPage => error::unimp("man page not yet implemented"), + builtins::ContentType::ManPage => Ok( + "NAME\n : - Null command.\n\nSYNOPSIS\n :\n\nDESCRIPTION\n Null command.\n\n No effect; the command does nothing.\n\n Exit Status:\n Always succeeds.\n" + .into(), + ), } } diff --git a/crates/brush-builtins-vendored/src/complete.rs b/crates/brush-builtins-vendored/src/complete.rs index a2b2c7417..c837fded5 100644 --- a/crates/brush-builtins-vendored/src/complete.rs +++ b/crates/brush-builtins-vendored/src/complete.rs @@ -3,7 +3,7 @@ use std::{collections::HashMap, fmt::Write as _, io::Write}; use brush_core::{ ExecutionExitCode, ExecutionResult, builtins, completion::{self, CompleteAction, CompleteOption, Spec}, - error, escape, + escape, }; use clap::Parser; @@ -188,6 +188,30 @@ impl CommonCompleteCommandArgs { actions } + + fn has_completion_spec(&self) -> bool { + !self.options.is_empty() + || !self.actions.is_empty() + || self.glob_pattern.is_some() + || self.word_list.is_some() + || self.function_name.is_some() + || self.command.is_some() + || self.filter_pattern.is_some() + || self.prefix.is_some() + || self.suffix.is_some() + || self.action_alias + || self.action_builtin + || self.action_command + || self.action_directory + || self.action_exported + || self.action_file + || self.action_group + || self.action_job + || self.action_keyword + || self.action_service + || self.action_user + || self.action_variable + } } /// Configure programmable command completion. @@ -234,7 +258,7 @@ impl builtins::Command for CompleteCommand { || self.use_for_initial_word || self.names.is_empty() { - self.process_global(&mut context)?; + result = self.process_global(&mut context)?; } else { for name in &self.names { if !self.try_process_for_command(&mut context, name.as_str())? { @@ -251,7 +275,7 @@ impl CompleteCommand { fn process_global( &self, context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, - ) -> Result<(), brush_core::Error> { + ) -> Result { // Read options before taking mutable borrow on completion_config let extended_globbing = context.shell.options().extended_globbing; @@ -272,13 +296,20 @@ impl CompleteCommand { }; // Treat 'complete' with no options the same as 'complete -p'. - if self.print || (!self.remove && target_spec.is_none()) { + if self.print + || (!self.remove && target_spec.is_none() && !self.common_args.has_completion_spec()) + { if let Some(target_spec) = target_spec { if let Some(existing_spec) = target_spec { let existing_spec = existing_spec.clone(); Self::display_spec(context, Some(special_option_name), None, &existing_spec)?; } else { - return error::unimp("special spec not found"); + writeln!( + context.stderr(), + "complete: {}: no completion specification", + Self::special_spec_name(special_option_name) + )?; + return Ok(ExecutionResult::general_error()); } } else { for (command_name, spec) in context.shell.completion_config().iter() { @@ -297,11 +328,22 @@ impl CompleteCommand { let mut new_spec = Some(self.common_args.create_spec(extended_globbing)); std::mem::swap(&mut new_spec, target_spec); } else { - return error::unimp("set unspecified spec"); + debug_assert!(self.common_args.has_completion_spec()); + writeln!(context.stderr(), "complete: invalid usage")?; + return Ok(ExecutionExitCode::InvalidUsage.into()); } } - Ok(()) + Ok(ExecutionResult::success()) + } + + fn special_spec_name(option_name: &str) -> &'static str { + match option_name { + "-D" => "_DefaultCmD_", + "-E" => "_EmptycmD_", + "-I" => "_InitialWorD_", + _ => "", + } } fn try_display_spec_for_command( @@ -510,7 +552,7 @@ impl builtins::Command for CompGenCommand { } }, completion::Answer::RestartCompletionProcess => { - return error::unimp("restart completion"); + return Ok(ExecutionResult::general_error()); }, } diff --git a/crates/brush-builtins-vendored/src/declare.rs b/crates/brush-builtins-vendored/src/declare.rs index 48123684b..6e425f66d 100644 --- a/crates/brush-builtins-vendored/src/declare.rs +++ b/crates/brush-builtins-vendored/src/declare.rs @@ -3,7 +3,6 @@ use std::{io::Write, sync::LazyLock}; use brush_core::{ ErrorKind, ExecutionResult, builtins, env::{self, EnvironmentLookup, EnvironmentScope}, - error, parser::ast, variables::{ self, ArrayLiteral, ShellValue, ShellValueLiteral, ShellValueUnsetType, ShellVariable, @@ -132,10 +131,6 @@ impl builtins::Command for DeclareCommand { return Ok(ExecutionResult::general_error()); } - if self.locals_inherit_from_prev_scope { - return error::unimp("declare -I"); - } - let mut result = ExecutionResult::success(); if !self.declarations.is_empty() { for declaration in &self.declarations { @@ -303,7 +298,28 @@ impl DeclareCommand { ShellValueUnsetType::Untyped }; - let mut var = ShellVariable::new(ShellValue::Unset(unset_type)); + let mut var = if create_var_local + && (self.locals_inherit_from_prev_scope + || context.shell.options().local_vars_inherit_value_and_attrs) + { + if let Some(prev_var) = context + .shell + .env() + .get_using_policy(name.as_str(), EnvironmentLookup::Anywhere) + { + if prev_var.is_readonly() { + return Err(ErrorKind::ReadonlyVariable.into()); + } + + let mut var = prev_var.clone(); + var.unset_treat_as_nameref(); + var + } else { + ShellVariable::new(ShellValue::Unset(unset_type)) + } + } else { + ShellVariable::new(ShellValue::Unset(unset_type)) + }; self.apply_attributes_before_update(&mut var)?; diff --git a/crates/brush-builtins-vendored/src/enable.rs b/crates/brush-builtins-vendored/src/enable.rs index 8a6da9d54..e584ee76d 100644 --- a/crates/brush-builtins-vendored/src/enable.rs +++ b/crates/brush-builtins-vendored/src/enable.rs @@ -1,6 +1,6 @@ use std::io::Write; -use brush_core::{ExecutionResult, builtins, error}; +use brush_core::{ExecutionResult, builtins}; use clap::Parser; use itertools::Itertools; @@ -44,15 +44,26 @@ impl builtins::Command for EnableCommand { ) -> Result { let mut result = ExecutionResult::success(); - if self.shared_object_path.is_some() { - return error::unimp("enable -f"); - } - if self.remove_loaded_builtin { - return error::unimp("enable -d"); + if let Some(shared_object_path) = &self.shared_object_path { + writeln!( + context.stderr(), + "{}: cannot open shared object {shared_object_path}: dynamic loading is not supported", + context.command_name + )?; + return Ok(ExecutionResult::general_error()); } if !self.names.is_empty() { for name in &self.names { + if self.remove_loaded_builtin { + if context.shell.builtins().contains_key(name) { + writeln!(context.stderr(), "{name}: not dynamically loaded")?; + } else { + writeln!(context.stderr(), "{name}: not a shell builtin")?; + } + result = ExecutionResult::general_error(); + continue; + } if let Some(builtin) = context.shell.builtin_mut(name) { builtin.disabled = self.disable; } else { diff --git a/crates/brush-builtins-vendored/src/exec.rs b/crates/brush-builtins-vendored/src/exec.rs index ed32ce4e5..cb6d35cc8 100644 --- a/crates/brush-builtins-vendored/src/exec.rs +++ b/crates/brush-builtins-vendored/src/exec.rs @@ -1,4 +1,7 @@ -use std::{borrow::Cow, os::unix::process::CommandExt}; +use std::{ + borrow::Cow, + os::unix::process::{CommandExt, ExitStatusExt}, +}; use brush_core::{ErrorKind, ExecutionExitCode, ExecutionResult, builtins, commands}; use clap::Parser; @@ -47,7 +50,7 @@ impl builtins::Command for ExecCommand { // expectation of returning. if context.shell.is_subshell() { if self.empty_environment || self.exec_as_login || self.name_for_argv0.is_some() { - return brush_core::error::unimp("exec with options in subshell not yet supported"); + return self.execute_external_in_subshell(context).await; } let cmd_cmd = crate::command::CommandCommand { @@ -58,16 +61,12 @@ impl builtins::Command for ExecCommand { return cmd_cmd.execute(context).await; } - let mut argv0 = Cow::Borrowed(self.name_for_argv0.as_ref().unwrap_or(&self.args[0])); - - if self.exec_as_login { - argv0 = Cow::Owned(std::format!("-{argv0}")); - } + let argv0 = self.argv0(); let mut cmd = commands::compose_std_command( &context, &self.args[0], - argv0.as_str(), + argv0.as_ref(), &self.args[1..], self.empty_environment, )?; @@ -81,3 +80,61 @@ impl builtins::Command for ExecCommand { } } } + +impl ExecCommand { + fn argv0(&self) -> Cow<'_, str> { + let argv0 = self + .name_for_argv0 + .as_deref() + .unwrap_or_else(|| self.args[0].as_str()); + + if self.exec_as_login { + Cow::Owned(std::format!("-{argv0}")) + } else { + Cow::Borrowed(argv0) + } + } + + async fn execute_external_in_subshell( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let argv0 = self.argv0(); + let cmd = commands::compose_std_command( + &context, + &self.args[0], + argv0.as_ref(), + &self.args[1..], + self.empty_environment, + )?; + + let mut cmd = tokio::process::Command::from(cmd); + cmd.kill_on_drop(true); + + let mut child = match cmd.spawn() { + Ok(child) => child, + Err(spawn_err) => { + if spawn_err.kind() == std::io::ErrorKind::NotFound { + return Ok(ExecutionExitCode::NotFound.into()); + } + + return Err(ErrorKind::from(spawn_err).into()); + }, + }; + + let status = child.wait().await?; + + if let Some(code) = status.code() { + #[expect(clippy::cast_sign_loss)] + return Ok(ExecutionResult::new((code & 0xff) as u8)); + } + + if let Some(signal) = status.signal() { + #[expect(clippy::cast_sign_loss)] + return Ok(ExecutionResult::new((signal & 0xff) as u8 + 128)); + } + + tracing::error!("unhandled process exit"); + Ok(ExecutionExitCode::NotFound.into()) + } +} diff --git a/crates/brush-builtins-vendored/src/false_.rs b/crates/brush-builtins-vendored/src/false_.rs index 375fc1d02..8a5184ec2 100644 --- a/crates/brush-builtins-vendored/src/false_.rs +++ b/crates/brush-builtins-vendored/src/false_.rs @@ -1,4 +1,4 @@ -use brush_core::{ExecutionResult, builtins, error}; +use brush_core::{ExecutionResult, builtins}; /// Return exit code 1. pub(crate) struct FalseCommand {} @@ -13,7 +13,10 @@ impl builtins::SimpleCommand for FalseCommand { builtins::ContentType::DetailedHelp => Ok("Returns a failure exit status.".into()), builtins::ContentType::ShortUsage => Ok("false".into()), builtins::ContentType::ShortDescription => Ok("false - fail".into()), - builtins::ContentType::ManPage => error::unimp("man page not yet implemented"), + builtins::ContentType::ManPage => Ok( + "NAME\n false - Return an unsuccessful result.\n\nSYNOPSIS\n false\n\nDESCRIPTION\n Return an unsuccessful result.\n\n Exit Status:\n Always fails.\n\nSEE ALSO\n bash(1)\n" + .into(), + ), } } diff --git a/crates/brush-builtins-vendored/src/fc.rs b/crates/brush-builtins-vendored/src/fc.rs index 3cb209f91..f6483c160 100644 --- a/crates/brush-builtins-vendored/src/fc.rs +++ b/crates/brush-builtins-vendored/src/fc.rs @@ -1,4 +1,8 @@ -use std::io::Write; +use std::{ + fs::{self, OpenOptions}, + io::Write, + path::{Path, PathBuf}, +}; use brush_core::{ExecutionResult, builtins, error, history}; use clap::Parser; @@ -50,7 +54,7 @@ impl builtins::Command for FcCommand { return self.do_list(&context); } - error::unimp("fc editor mode is not yet implemented") + self.do_edit(context).await } } @@ -88,6 +92,96 @@ impl FcCommand { Ok(ExecutionResult::success()) } + async fn do_edit( + &self, + context: brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + ) -> Result { + let history = context + .shell + .history() + .ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?; + + let (first_idx, last_idx, reverse) = self.resolve_range(history)?; + let mut commands = String::new(); + let indices: Vec = if reverse { + (first_idx..=last_idx).rev().collect() + } else { + (first_idx..=last_idx).collect() + }; + + for idx in indices { + let item = history + .get(idx) + .ok_or_else(|| brush_core::Error::from(error::ErrorKind::HistoryItemNotFound))?; + commands.push_str(&item.command_line); + commands.push('\n'); + } + + let editor = self.editor_name(&context); + if editor.as_deref() != Some("-") { + let temp_file = FcTempFile::create()?; + fs::write(temp_file.path(), commands)?; + + let edit_cmd = format!( + "{} {}", + editor.as_deref().unwrap_or("vi"), + shell_quote_path(temp_file.path()) + ); + let source_info = brush_core::SourceInfo::from("(fc editor)"); + let edit_result = context + .shell + .run_string(edit_cmd, &source_info, &context.params) + .await?; + if !edit_result.is_success() { + return Ok(edit_result); + } + + commands = fs::read_to_string(temp_file.path())?; + } + + let history_mut = context + .shell + .history_mut() + .ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?; + history_mut.remove_nth_item(history_mut.count().saturating_sub(1)); + + if commands.trim().is_empty() { + return Ok(ExecutionResult::success()); + } + + let source_info = brush_core::SourceInfo::from("(history)"); + let result = context + .shell + .run_string(commands.clone(), &source_info, &context.params) + .await?; + context.shell.add_to_history(commands.trim_end())?; + + Ok(result) + } + + fn editor_name( + &self, + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + ) -> Option { + if let Some(editor) = self.editor.as_ref().filter(|value| !value.is_empty()) { + return Some(editor.clone()); + } + + context + .shell + .env() + .get_str("FCEDIT", context.shell) + .filter(|value| !value.is_empty()) + .or_else(|| { + context + .shell + .env() + .get_str("EDITOR", context.shell) + .filter(|value| !value.is_empty()) + }) + .map(|value| value.into_owned()) + } + async fn do_execute( &self, context: brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, @@ -291,6 +385,55 @@ impl FcCommand { } } +struct FcTempFile { + path: PathBuf, +} + +impl FcTempFile { + fn create() -> Result { + let temp_dir = std::env::temp_dir(); + let process_id = std::process::id(); + + for attempt in 0_u32..100 { + let path = temp_dir.join(format!("brush-fc-{process_id}-{attempt}.sh")); + match OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(_) => return Ok(Self { path }), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {}, + Err(err) => return Err(err.into()), + } + } + + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "failed to create a unique fc temporary file", + ) + .into()) + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for FcTempFile { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +fn shell_quote_path(path: &Path) -> String { + let mut quoted = String::from("'"); + for ch in path.to_string_lossy().chars() { + if ch == '\'' { + quoted.push_str("'\\''"); + } else { + quoted.push(ch); + } + } + quoted.push('\''); + quoted +} + /// Returns the effective history count (excluding the fc command itself). fn effective_history_count(history: &history::History) -> usize { history.count().saturating_sub(1) diff --git a/crates/brush-builtins-vendored/src/history.rs b/crates/brush-builtins-vendored/src/history.rs index ce13134fe..e4aad4611 100644 --- a/crates/brush-builtins-vendored/src/history.rs +++ b/crates/brush-builtins-vendored/src/history.rs @@ -1,6 +1,6 @@ -use std::{io::Write, path::PathBuf}; +use std::{fs::File, io::Write, path::PathBuf}; -use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error, history}; +use brush_core::{ExecutionExitCode, ExecutionResult, builtins, history}; use clap::Parser; /// Query or manipulate the shell's command history. @@ -135,12 +135,24 @@ impl HistoryCommand { return Ok(ExecutionResult::success()); } - if self.append_rest_of_file_to_session.is_some() { - return error::unimp("history -n is not yet implemented"); + if let Some(read_option) = &self.append_rest_of_file_to_session { + if let Some(file_path) = + get_effective_history_file_path(config.default_history_file_path, read_option.as_ref()) + { + append_history_file_to_session(history, file_path, HistoryReadMode::Unread)?; + } + + return Ok(ExecutionResult::success()); } - if self.append_file_to_session.is_some() { - return error::unimp("history -r is not yet implemented"); + if let Some(read_option) = &self.append_file_to_session { + if let Some(file_path) = + get_effective_history_file_path(config.default_history_file_path, read_option.as_ref()) + { + append_history_file_to_session(history, file_path, HistoryReadMode::All)?; + } + + return Ok(ExecutionResult::success()); } if let Some(write_option) = &self.write_session_to_file { @@ -158,8 +170,8 @@ impl HistoryCommand { return Ok(ExecutionResult::success()); } - if self.expand_args.is_some() { - return error::unimp("history -p is not yet implemented"); + if let Some(args) = &self.expand_args { + return expand_history_args(history, args, stdout, stderr); } if let Some(args) = &self.append_args_to_session { @@ -179,6 +191,219 @@ impl HistoryCommand { } } +fn expand_history_args( + history: &history::History, + args: &[String], + mut stdout: impl Write, + mut stderr: impl Write, +) -> Result { + let mut result = ExecutionResult::success(); + + for arg in args { + match expand_history_arg(history, arg) { + Ok(expanded) => { + writeln!(stdout, "{expanded}")?; + }, + Err(()) => { + writeln!(stderr, "history: {arg}: history expansion failed")?; + result = ExecutionResult::general_error(); + }, + } + } + + Ok(result) +} + +fn expand_history_arg(history: &history::History, arg: &str) -> Result { + let chars: Vec = arg.chars().collect(); + let mut expanded = String::new(); + let mut i = 0; + + while i < chars.len() { + if chars[i] != '!' { + expanded.push(chars[i]); + i += 1; + continue; + } + + i += 1; + if i == chars.len() { + expanded.push('!'); + break; + } + + let event = match chars[i] { + '!' => { + i += 1; + latest_history_event(history)? + }, + '#' => { + i += 1; + let current_line = expanded.clone(); + expanded.push_str(¤t_line); + continue; + }, + ':' => latest_history_event(history)?, + '$' | '^' | '*' => { + let event = latest_history_event(history)?; + let selected = select_history_words(&event, chars[i], None)?; + i += 1; + expanded.push_str(&selected); + continue; + }, + '-' => { + i += 1; + let (offset, next_i) = parse_history_number(&chars, i).ok_or(())?; + i = next_i; + relative_history_event(history, offset)? + }, + '?' => { + i += 1; + let start = i; + while i < chars.len() && chars[i] != '?' { + i += 1; + } + let needle: String = chars[start..i].iter().collect(); + if i < chars.len() && chars[i] == '?' { + i += 1; + } + find_history_event(history, &needle, HistorySearchMode::Contains)? + }, + c if c.is_ascii_digit() => { + let (number, next_i) = parse_history_number(&chars, i).ok_or(())?; + i = next_i; + numbered_history_event(history, number)? + }, + c if is_history_event_char(c) => { + let start = i; + while i < chars.len() && is_history_event_char(chars[i]) { + i += 1; + } + let prefix: String = chars[start..i].iter().collect(); + find_history_event(history, &prefix, HistorySearchMode::Prefix)? + }, + _ => { + expanded.push('!'); + continue; + }, + }; + + if i < chars.len() && chars[i] == ':' { + i += 1; + if i == chars.len() { + return Err(()); + } + let selector = chars[i]; + i += 1; + let number = if selector.is_ascii_digit() { + let (number, next_i) = parse_history_number_from_first(&chars, i - 1); + i = next_i; + Some(number) + } else { + None + }; + let selected = select_history_words(&event, selector, number)?; + expanded.push_str(&selected); + } else { + expanded.push_str(&event); + } + } + + Ok(expanded) +} + +fn latest_history_event(history: &history::History) -> Result { + history + .iter() + .last() + .map(|item| item.command_line.clone()) + .ok_or(()) +} + +fn numbered_history_event(history: &history::History, number: usize) -> Result { + if number == 0 { + return Err(()); + } + + history + .get(number - 1) + .map(|item| item.command_line.clone()) + .ok_or(()) +} + +fn relative_history_event(history: &history::History, offset: usize) -> Result { + let count = history.count(); + if offset == 0 || offset > count { + return Err(()); + } + + numbered_history_event(history, count - offset + 1) +} + +enum HistorySearchMode { + Prefix, + Contains, +} + +fn find_history_event( + history: &history::History, + needle: &str, + mode: HistorySearchMode, +) -> Result { + let mut match_result = None; + for item in history.iter() { + let matches = match mode { + HistorySearchMode::Prefix => item.command_line.starts_with(needle), + HistorySearchMode::Contains => item.command_line.contains(needle), + }; + if matches { + match_result = Some(item.command_line.clone()); + } + } + + match_result.ok_or(()) +} + +fn select_history_words( + event: &str, + selector: char, + number: Option, +) -> Result { + let words: Vec<&str> = event.split_whitespace().collect(); + match selector { + '0'..='9' => { + let index = number.ok_or(())?; + words.get(index).map(|word| (*word).to_owned()).ok_or(()) + }, + '^' => words.get(1).map(|word| (*word).to_owned()).ok_or(()), + '$' => words.last().map(|word| (*word).to_owned()).ok_or(()), + '*' => Ok(words.get(1..).unwrap_or_default().join(" ")), + 'p' => Ok(event.to_owned()), + _ => Err(()), + } +} + +fn parse_history_number(chars: &[char], i: usize) -> Option<(usize, usize)> { + if i == chars.len() || !chars[i].is_ascii_digit() { + return None; + } + + Some(parse_history_number_from_first(chars, i)) +} + +fn parse_history_number_from_first(chars: &[char], mut i: usize) -> (usize, usize) { + let mut value = 0; + while i < chars.len() && chars[i].is_ascii_digit() { + value = value * 10 + chars[i].to_digit(10).unwrap_or_default() as usize; + i += 1; + } + (value, i) +} + +fn is_history_event_char(c: char) -> bool { + c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '/') +} + fn display_history( history: &history::History, config: &HistoryConfig, @@ -213,6 +438,30 @@ fn display_history( Ok(()) } +enum HistoryReadMode { + All, + Unread, +} + +fn append_history_file_to_session( + history: &mut history::History, + file_path: PathBuf, + mode: HistoryReadMode, +) -> Result<(), brush_core::Error> { + let file = File::open(file_path)?; + let imported_history = history::History::import(file)?; + let already_read_count = match mode { + HistoryReadMode::All => 0, + HistoryReadMode::Unread => history.iter().filter(|item| !item.dirty).count(), + }; + + for item in imported_history.iter().skip(already_read_count) { + history.add(item.clone())?; + } + + Ok(()) +} + fn get_effective_history_file_path( default_history_file_path: Option, option: Option<&String>, @@ -222,6 +471,12 @@ fn get_effective_history_file_path( #[cfg(test)] mod tests { + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + use anyhow::Result; use pretty_assertions::{assert_eq, assert_matches}; @@ -240,4 +495,49 @@ mod tests { Ok(()) } + + #[test] + fn test_append_history_file_to_session_reads_all_entries() -> Result<()> { + let file_path = write_temp_history("history-r", "one\ntwo\n")?; + let mut history = history::History::default(); + history.add(history::Item::new("local"))?; + + append_history_file_to_session(&mut history, file_path.clone(), HistoryReadMode::All)?; + + assert_eq!(history.count(), 3); + assert_eq!(history.get(0).map(|item| item.command_line.as_str()), Some("local")); + assert_eq!(history.get(1).map(|item| item.command_line.as_str()), Some("one")); + assert_eq!(history.get(2).map(|item| item.command_line.as_str()), Some("two")); + assert_eq!(history.get(1).map(|item| item.dirty), Some(false)); + fs::remove_file(file_path)?; + + Ok(()) + } + + #[test] + fn test_append_history_file_to_session_reads_unread_entries_after_clean_history() -> Result<()> { + let initial_file_path = write_temp_history("history-n-initial", "one\ntwo\n")?; + let mut history = history::History::import(fs::File::open(&initial_file_path)?)?; + history.add(history::Item::new("local"))?; + + let updated_file_path = write_temp_history("history-n-updated", "one\ntwo\nthree\n")?; + append_history_file_to_session(&mut history, updated_file_path.clone(), HistoryReadMode::Unread)?; + + assert_eq!(history.count(), 4); + assert_eq!(history.get(2).map(|item| item.command_line.as_str()), Some("local")); + assert_eq!(history.get(3).map(|item| item.command_line.as_str()), Some("three")); + assert_eq!(history.get(3).map(|item| item.dirty), Some(false)); + fs::remove_file(initial_file_path)?; + fs::remove_file(updated_file_path)?; + + Ok(()) + } + + fn write_temp_history(name: &str, contents: &str) -> Result { + let mut path = std::env::temp_dir(); + let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + path.push(format!("brush-{name}-{nanos}.history")); + fs::write(&path, contents)?; + Ok(path) + } } diff --git a/crates/brush-builtins-vendored/src/jobs.rs b/crates/brush-builtins-vendored/src/jobs.rs index e4dd52c76..0366a1970 100644 --- a/crates/brush-builtins-vendored/src/jobs.rs +++ b/crates/brush-builtins-vendored/src/jobs.rs @@ -1,6 +1,6 @@ use std::io::Write; -use brush_core::{ExecutionResult, builtins, error, jobs}; +use brush_core::{ExecutionResult, builtins, jobs}; use clap::Parser; /// Manage jobs. @@ -38,22 +38,31 @@ impl builtins::Command for JobsCommand { &self, context: brush_core::ExecutionContext<'_, SE>, ) -> Result { - if self.also_show_pids { - return error::unimp("jobs -l"); - } if self.list_changed_only { - return error::unimp("jobs -n"); + for (job, result) in context.shell.jobs_mut().poll()? { + result?; + self.display_job(&context, &job)?; + } + return Ok(ExecutionResult::success()); } + let mut exit_code = ExecutionResult::success(); if self.job_specs.is_empty() { for job in &context.shell.jobs().jobs { self.display_job(&context, job)?; } } else { - return error::unimp("jobs with job specs"); + for job_spec in &self.job_specs { + if let Some(job) = resolve_job_spec(context.shell.jobs(), job_spec) { + self.display_job(&context, job)?; + } else { + writeln!(context.stderr(), "{}: no such job: {}", context.command_name, job_spec)?; + exit_code = ExecutionResult::general_error(); + } + } } - Ok(ExecutionResult::success()) + Ok(exit_code) } } @@ -74,6 +83,14 @@ impl JobsCommand { if let Some(pid) = job.representative_pid() { writeln!(context.stdout(), "{pid}")?; } + } else if self.also_show_pids { + write!(context.stdout(), "[{}]{:3}", job.id, job.annotation())?; + if let Some(pid) = job.representative_pid() { + write!(context.stdout(), "{pid}\t")?; + } else { + write!(context.stdout(), "\t")?; + } + writeln!(context.stdout(), "{}\t{}", job.state, job.command_line)?; } else { writeln!(context.stdout(), "{job}")?; } @@ -81,3 +98,13 @@ impl JobsCommand { Ok(()) } } + +fn resolve_job_spec<'a>(job_manager: &'a jobs::JobManager, job_spec: &str) -> Option<&'a jobs::Job> { + match job_manager.resolve_job_spec_selector(job_spec)? { + jobs::JobSelector::JobId(id) => job_manager.jobs.iter().find(|job| job.id == id), + jobs::JobSelector::ProcessId(pid) => job_manager + .jobs + .iter() + .find(|job| job.representative_pid().is_some_and(|job_pid| job_pid == pid)), + } +} diff --git a/crates/brush-builtins-vendored/src/mapfile.rs b/crates/brush-builtins-vendored/src/mapfile.rs index a896d9a63..779f60104 100644 --- a/crates/brush-builtins-vendored/src/mapfile.rs +++ b/crates/brush-builtins-vendored/src/mapfile.rs @@ -1,6 +1,6 @@ use std::io::{Read, Write}; -use brush_core::{ErrorKind, ExecutionExitCode, ExecutionResult, builtins, env, error, variables}; +use brush_core::{ErrorKind, ExecutionExitCode, ExecutionResult, builtins, env, escape, variables}; use clap::Parser; /// Read lines from standard input into an indexed array variable. @@ -48,11 +48,8 @@ impl builtins::Command for MapFileCommand { async fn execute( &self, - context: brush_core::ExecutionContext<'_, SE>, + mut context: brush_core::ExecutionContext<'_, SE>, ) -> Result { - if self.callback_group_size != 5000 || self.callback.is_some() { - return error::unimp("mapfile -C/-c is not yet implemented"); - } if let Some(origin) = self.origin { if origin < 0 { @@ -81,49 +78,39 @@ impl builtins::Command for MapFileCommand { .try_fd(self.fd) .ok_or_else(|| ErrorKind::BadFileDescriptor(self.fd))?; - // Read! - let results = self.read_entries(input_file)?; - - if let Some(origin) = self.origin { - // -O: preserve existing array, assign at offset. - for (elem_idx, (_key, value)) in results.0.into_iter().enumerate() { - // If the user is getting to wraparounds in *bash*, they got bigger problems. - #[allow(clippy::cast_possible_wrap)] - let elem_idx = elem_idx as i64; - context.shell.env_mut().update_or_add_array_element( - &self.array_var_name, - (elem_idx + origin).to_string(), - value, - |_| Ok(()), - env::EnvironmentLookup::Anywhere, - env::EnvironmentScope::Global, - )?; - } - } else { - // No -O: replace the entire variable (clears existing). + // Read and assign entries. When no origin is specified, bash clears the + // target array before reading; callbacks then see earlier assigned entries + // but not the entry that is currently being delivered to the callback. + if self.origin.is_none() { context.shell.env_mut().update_or_add( &self.array_var_name, - variables::ShellValueLiteral::Array(results), + variables::ShellValueLiteral::Array(variables::ArrayLiteral(vec![])), |_| Ok(()), env::EnvironmentLookup::Anywhere, env::EnvironmentScope::Global, )?; } + if let Some(result) = self.read_entries(input_file, &mut context).await? { + return Ok(result); + } + Ok(ExecutionResult::success()) } } impl MapFileCommand { - fn read_entries( + async fn read_entries( &self, mut input_file: brush_core::openfiles::OpenFile, - ) -> Result { + context: &mut brush_core::ExecutionContext<'_, SE>, + ) -> Result, brush_core::Error> { let _term_mode = setup_terminal_settings(&input_file)?; - let mut entries = vec![]; + let mut entry_count = 0usize; let mut read_count = 0; let max_count = self.max_count.try_into()?; + let callback_group_size: usize = self.callback_group_size.try_into()?; let delimiter = match &self.delimiter { Some(d) if d.is_empty() => b'\0', Some(d) => d.as_bytes().first().copied().unwrap_or(b'\n'), @@ -132,7 +119,7 @@ impl MapFileCommand { let mut buf = [0u8; 1]; - while max_count == 0 || entries.len() < max_count { + while max_count == 0 || entry_count < max_count { let mut line = vec![]; let mut saw_delimiter = false; @@ -168,14 +155,54 @@ impl MapFileCommand { } let line_str = String::from_utf8_lossy(&line).to_string(); + let array_index = self.origin.unwrap_or(0) + i64::try_from(entry_count)?; - entries.push((None, line_str)); + if let Some(callback) = &self.callback + && (entry_count + 1) % callback_group_size == 0 + { + let result = run_callback(callback, array_index, &line_str, context).await?; + if !result.is_normal_flow() { + return Ok(Some(result)); + } + } + + context.shell.env_mut().update_or_add_array_element( + &self.array_var_name, + array_index.to_string(), + line_str, + |_| Ok(()), + env::EnvironmentLookup::Anywhere, + env::EnvironmentScope::Global, + )?; + + entry_count += 1; } - Ok(variables::ArrayLiteral(entries)) + Ok(None) } } +async fn run_callback( + callback: &str, + array_index: i64, + line: &str, + context: &mut brush_core::ExecutionContext<'_, SE>, +) -> Result { + let index_arg = array_index.to_string(); + let index_arg = escape::quote_if_needed(&index_arg, escape::QuoteMode::SingleQuote); + let line_arg = escape::quote_if_needed(line, escape::QuoteMode::SingleQuote); + + let mut command = String::with_capacity(callback.len() + index_arg.len() + line_arg.len() + 2); + command.push_str(callback); + command.push(' '); + command.push_str(index_arg.as_ref()); + command.push(' '); + command.push_str(line_arg.as_ref()); + + let source_info = context.shell.call_stack().current_pos_as_source_info(); + context.shell.run_string(command, &source_info, &context.params).await +} + fn setup_terminal_settings( file: &brush_core::openfiles::OpenFile, ) -> Result, brush_core::Error> { diff --git a/crates/brush-builtins-vendored/src/read.rs b/crates/brush-builtins-vendored/src/read.rs index cfce2c1b0..f31128553 100644 --- a/crates/brush-builtins-vendored/src/read.rs +++ b/crates/brush-builtins-vendored/src/read.rs @@ -84,12 +84,6 @@ impl builtins::Command for ReadCommand { &self, context: brush_core::ExecutionContext<'_, SE>, ) -> Result { - if self.use_readline { - return error::unimp("read -e"); - } - if self.initial_text.is_some() { - return error::unimp("read -i"); - } // Validate timeout value if provided. if let Some(result) = self.validate_timeout(&context)? { @@ -105,6 +99,17 @@ impl builtins::Command for ReadCommand { let input_stream = context .try_fd(fd_num) .ok_or_else(|| ErrorKind::BadFileDescriptor(fd_num))?; + // Bash only uses readline for `read -e` when reading from a terminal. + // For non-terminal input, `-e` and `-i` are accepted but do not affect + // the bytes read. `-i` only supplies initial text to readline, so without + // an available readline-backed terminal path it is likewise a no-op. + if self.use_readline && input_stream.is_terminal() { + return error::unimp(if self.initial_text.is_some() { + "read -e -i" + } else { + "read -e" + }); + } // Retrieve effective value of IFS for splitting. // We convert to owned String to release the borrow before the mutable borrow @@ -272,6 +277,7 @@ fn build_variable_fields( /// /// This enum clearly represents all possible outcomes of `read_line()`, /// making the contract with callers explicit. +#[derive(Debug)] enum ReadResult { /// Successfully read a complete line (delimiter or char limit reached). Line(String), diff --git a/crates/brush-builtins-vendored/src/true_.rs b/crates/brush-builtins-vendored/src/true_.rs index f75cd8d59..739b80779 100644 --- a/crates/brush-builtins-vendored/src/true_.rs +++ b/crates/brush-builtins-vendored/src/true_.rs @@ -1,8 +1,21 @@ -use brush_core::{ExecutionResult, builtins, error}; +use brush_core::{ExecutionResult, builtins}; /// No-op command. Same with :. pub(crate) struct TrueCommand {} +const MAN_PAGE: &str = "\ +TRUE(1) + +NAME + true - return a successful result + +SYNOPSIS + true + +DESCRIPTION + The true utility returns a successful exit status. +"; + impl builtins::SimpleCommand for TrueCommand { fn get_content( _name: &str, @@ -13,7 +26,7 @@ impl builtins::SimpleCommand for TrueCommand { builtins::ContentType::DetailedHelp => Ok("Returns a successful exit status.".into()), builtins::ContentType::ShortUsage => Ok("true".into()), builtins::ContentType::ShortDescription => Ok("true - success".into()), - builtins::ContentType::ManPage => error::unimp("man page not yet implemented"), + builtins::ContentType::ManPage => Ok(MAN_PAGE.into()), } } diff --git a/crates/brush-builtins-vendored/src/umask.rs b/crates/brush-builtins-vendored/src/umask.rs index 76fb9298d..dff7552ae 100644 --- a/crates/brush-builtins-vendored/src/umask.rs +++ b/crates/brush-builtins-vendored/src/umask.rs @@ -33,7 +33,9 @@ impl builtins::Command for UmaskCommand { let parsed = brush_core::int_utils::parse(mode.as_str(), 8)?; set_umask(parsed)?; } else { - return brush_core::error::unimp("umask setting mode from symbolic value"); + let current_umask = get_umask()?; + let parsed = parse_symbolic_umask(mode, current_umask)?; + set_umask(parsed)?; } } else { let umask = get_umask()?; @@ -74,6 +76,81 @@ cfg_if! { } } +fn parse_symbolic_umask(mode: &str, current_umask: u32) -> Result { + let mut umask = current_umask & 0o777; + let mut chars = mode.chars().peekable(); + let mut saw_clause = false; + + while chars.peek().is_some() { + saw_clause = true; + + let mut who_bits = 0; + while let Some(&ch) = chars.peek() { + let bits = match ch { + 'u' => 0o700, + 'g' => 0o070, + 'o' => 0o007, + 'a' => 0o777, + _ => break, + }; + who_bits |= bits; + chars.next(); + } + if who_bits == 0 { + who_bits = 0o777; + } + + loop { + let op = chars.next().ok_or(ErrorKind::InvalidUmask)?; + if !matches!(op, '+' | '-' | '=') { + return Err(ErrorKind::InvalidUmask.into()); + } + + let mut perm_bits = 0; + while let Some(&ch) = chars.peek() { + let bits = match ch { + 'r' => 0o444, + 'w' => 0o222, + 'x' => 0o111, + '+' | '-' | '=' | ',' => break, + _ => return Err(ErrorKind::InvalidUmask.into()), + }; + perm_bits |= bits & who_bits; + chars.next(); + } + + match op { + '+' => umask &= !perm_bits, + '-' => umask |= perm_bits, + '=' => { + umask |= who_bits; + umask &= !perm_bits; + } + _ => unreachable!(), + } + + match chars.peek() { + Some(',') => { + chars.next(); + if chars.peek().is_none() { + return Err(ErrorKind::InvalidUmask.into()); + } + break; + } + Some('+' | '-' | '=') => continue, + Some(_) => return Err(ErrorKind::InvalidUmask.into()), + None => break, + } + } + } + + if saw_clause { + Ok(umask as nix::sys::stat::mode_t) + } else { + Err(ErrorKind::InvalidUmask.into()) + } +} + fn set_umask(value: nix::sys::stat::mode_t) -> Result<(), brush_core::Error> { // value of mode_t can be platform dependent let mode = nix::sys::stat::Mode::from_bits(value).ok_or_else(|| ErrorKind::InvalidUmask)?; @@ -96,3 +173,39 @@ fn symbolic_mask_from_bits(bits: u32) -> String { result } + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(mode: &str, current_umask: u32) -> u32 { + parse_symbolic_umask(mode, current_umask).unwrap() as u32 + } + + #[test] + fn parses_symbolic_umask_assignments() { + assert_eq!(parse("u=rwx,g=rx,o=", 0o022), 0o027); + assert_eq!(parse("=r", 0o022), 0o333); + assert_eq!(parse("a=", 0o022), 0o777); + assert_eq!(parse("u=", 0o022), 0o722); + } + + #[test] + fn parses_symbolic_umask_incremental_ops() { + assert_eq!(parse("u+rw", 0o777), 0o177); + assert_eq!(parse("g-w", 0o022), 0o022); + assert_eq!(parse("+x", 0o022), 0o022); + assert_eq!(parse("u+r-w", 0o777), 0o377); + assert_eq!(parse("a+r,u-w", 0o777), 0o333); + } + + #[test] + fn rejects_invalid_symbolic_umasks() { + assert!(parse_symbolic_umask("", 0o022).is_err()); + assert!(parse_symbolic_umask("u", 0o022).is_err()); + assert!(parse_symbolic_umask("u+z", 0o022).is_err()); + assert!(parse_symbolic_umask("z+r", 0o022).is_err()); + assert!(parse_symbolic_umask("u=,", 0o022).is_err()); + assert!(parse_symbolic_umask("u,,g=r", 0o022).is_err()); + } +} diff --git a/crates/brush-builtins-vendored/src/unset.rs b/crates/brush-builtins-vendored/src/unset.rs index 38e952fe3..465022416 100644 --- a/crates/brush-builtins-vendored/src/unset.rs +++ b/crates/brush-builtins-vendored/src/unset.rs @@ -42,11 +42,12 @@ impl builtins::Command for UnsetCommand { &self, context: brush_core::ExecutionContext<'_, SE>, ) -> Result { - // - // TODO(nameref): implement nameref - // if self.name_interpretation.name_references { - return brush_core::error::unimp("unset: name references are not yet implemented"); + for name in &self.names { + unset_name_reference(context.shell, name)?; + } + + return Ok(ExecutionResult::success()); } let unspecified = self.name_interpretation.unspecified(); @@ -91,6 +92,27 @@ impl builtins::Command for UnsetCommand { Ok(ExecutionResult::success()) } } +fn unset_name_reference( + shell: &mut Shell, + name: &str, +) -> Result { + let Ok(brush_parser::word::Parameter::Named(name)) = + brush_parser::word::parse_parameter(name, &shell.parser_options()) + else { + return Ok(false); + }; + + if shell + .env() + .get(name.as_str()) + .is_some_and(|(_, var)| var.is_treated_as_nameref()) + { + shell.env_mut().unset(name.as_str()).map(|v| v.is_some()) + } else { + Ok(false) + } +} + fn unset_array_index( shell: &mut Shell, diff --git a/crates/brush-builtins-vendored/src/wait.rs b/crates/brush-builtins-vendored/src/wait.rs index 608be5d97..a9acb7e79 100644 --- a/crates/brush-builtins-vendored/src/wait.rs +++ b/crates/brush-builtins-vendored/src/wait.rs @@ -1,6 +1,12 @@ use std::io::Write; -use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error}; +use brush_core::{ + ExecutionExitCode, ExecutionResult, builtins, + env::{EnvironmentLookup, EnvironmentScope}, + int_utils, + jobs::{Job, JobSelector}, + variables::ShellValueLiteral, +}; use clap::Parser; /// Wait for jobs to terminate. @@ -31,37 +37,74 @@ impl builtins::Command for WaitCommand { &self, context: brush_core::ExecutionContext<'_, SE>, ) -> Result { - if self.wait_for_terminate { - return error::unimp("wait -f"); - } - if self.wait_for_first_or_next { - return error::unimp("wait -n"); - } - if self.variable_to_receive_id.is_some() { - return error::unimp("wait -p"); + if let Some(variable) = &self.variable_to_receive_id { + context.shell.env_mut().unset(variable)?; } let mut result = ExecutionResult::success(); + if self.wait_for_first_or_next { + let selectors = match resolve_wait_selectors(&context, &self.ids)? { + WaitSelectorResolution::Selectors(selectors) => selectors, + WaitSelectorResolution::Failure(result) => return Ok(result), + }; + let Some(waited) = context.shell.jobs_mut().wait_next(&selectors).await? else { + return Ok(ExecutionExitCode::NotFound.into()); + }; + if let Some(variable) = &self.variable_to_receive_id { + assign_wait_variable(context.shell, variable, waited.identifier)?; + } + return Ok(waited.result); + } + + let mut waited_identifier = None; if !self.ids.is_empty() { for id in &self.ids { if id.starts_with('%') { // It's a job spec. if let Some(job) = context.shell.jobs_mut().resolve_job_spec(id) { - job.wait().await?; + waited_identifier = Some(job_identifier(job)); + result = if self.wait_for_terminate { + job.wait_for_termination().await? + } else { + job.wait().await? + }; } else { writeln!(context.stderr(), "{}: no such job: {}", context.command_name, id)?; result = ExecutionExitCode::GeneralError.into(); } + } else if let Ok(pid) = int_utils::parse::(id, 10) { + if let Some(job) = context.shell.jobs_mut().resolve_process_id(pid) { + waited_identifier = Some(pid.to_string()); + result = if self.wait_for_terminate { + job.wait_for_termination().await? + } else { + job.wait().await? + }; + } else { + writeln!( + context.stderr(), + "{}: pid {pid} is not a child of this shell", + context.command_name + )?; + + result = ExecutionExitCode::NotFound.into(); + } } else { - // It's a process ID. - return error::unimp("wait with process IDs"); + writeln!(context.stderr(), "{}: no such job: {}", context.command_name, id)?; + + result = ExecutionExitCode::GeneralError.into(); } } } else { // Wait for all jobs. - let jobs = context.shell.jobs_mut().wait_all().await?; + let jobs = if self.wait_for_terminate { + context.shell.jobs_mut().wait_all_for_termination().await? + } else { + context.shell.jobs_mut().wait_all().await? + }; + waited_identifier = jobs.last().map(job_identifier); if context.shell.options().enable_job_control { for job in jobs { @@ -70,6 +113,71 @@ impl builtins::Command for WaitCommand { } } + if let (Some(variable), Some(identifier)) = (&self.variable_to_receive_id, waited_identifier) { + assign_wait_variable(context.shell, variable, identifier)?; + } + Ok(result) } } + +enum WaitSelectorResolution { + Selectors(Vec), + Failure(ExecutionResult), +} + +fn resolve_wait_selectors( + context: &brush_core::ExecutionContext<'_, SE>, + ids: &[String], +) -> Result { + let mut selectors = Vec::new(); + for id in ids { + if id.starts_with('%') { + if let Some(selector) = context.shell.jobs().resolve_job_spec_selector(id) { + selectors.push(selector); + } else { + writeln!(context.stderr(), "{}: no such job: {}", context.command_name, id)?; + return Ok(WaitSelectorResolution::Failure( + ExecutionExitCode::GeneralError.into(), + )); + } + } else if let Ok(pid) = int_utils::parse::(id, 10) { + if context.shell.jobs().contains_process_id(pid) { + selectors.push(JobSelector::ProcessId(pid)); + } else { + writeln!( + context.stderr(), + "{}: pid {pid} is not a child of this shell", + context.command_name + )?; + return Ok(WaitSelectorResolution::Failure(ExecutionExitCode::NotFound.into())); + } + } else { + writeln!(context.stderr(), "{}: no such job: {}", context.command_name, id)?; + return Ok(WaitSelectorResolution::Failure( + ExecutionExitCode::GeneralError.into(), + )); + } + } + Ok(WaitSelectorResolution::Selectors(selectors)) +} + +fn assign_wait_variable( + shell: &mut brush_core::Shell, + name: &str, + value: String, +) -> Result<(), brush_core::Error> { + shell.env_mut().update_or_add( + name, + ShellValueLiteral::Scalar(value), + |_| Ok(()), + EnvironmentLookup::Anywhere, + EnvironmentScope::Global, + ) +} + +fn job_identifier(job: &Job) -> String { + job + .representative_pid() + .map_or_else(|| job.id.to_string(), |pid| pid.to_string()) +} diff --git a/crates/brush-core-vendored/Cargo.toml b/crates/brush-core-vendored/Cargo.toml index e6d064bdd..14a497b40 100644 --- a/crates/brush-core-vendored/Cargo.toml +++ b/crates/brush-core-vendored/Cargo.toml @@ -148,6 +148,7 @@ features = [ "rt-multi-thread", "signal", "sync", + "time", ] [target.'cfg(target_family = "wasm")'.dependencies.tokio] @@ -156,6 +157,7 @@ features = [ "io-util", "macros", "rt", + "time", "sync", ] diff --git a/crates/brush-core-vendored/src/builtins.rs b/crates/brush-core-vendored/src/builtins.rs index 0dcf698da..602bc82cd 100644 --- a/crates/brush-core-vendored/src/builtins.rs +++ b/crates/brush-core-vendored/src/builtins.rs @@ -174,8 +174,138 @@ impl Registration { } } -fn get_builtin_man_page(_name: &str, _command: &clap::Command) -> Result { - error::unimp("man page rendering is not yet implemented") +fn get_builtin_man_page(name: &str, command: &clap::Command) -> Result { + let mut man_page = String::new(); + + append_man_section(&mut man_page, "NAME"); + let description = command + .get_about() + .map_or_else(String::new, std::string::ToString::to_string); + if description.is_empty() { + man_page.push_str(name); + man_page.push('\n'); + } else { + man_page.push_str(name); + man_page.push_str(" - "); + man_page.push_str(&description); + man_page.push('\n'); + } + + append_man_section(&mut man_page, "SYNOPSIS"); + let mut usage_command = command.clone(); + let usage = usage_command.render_usage(); + man_page.push_str(&usage.to_string()); + man_page.push('\n'); + + if let Some(about) = command.get_long_about().or_else(|| command.get_about()) { + append_man_section(&mut man_page, "DESCRIPTION"); + man_page.push_str(&about.to_string()); + man_page.push('\n'); + } + + append_man_arguments_section(&mut man_page, command); + append_man_options_section(&mut man_page, command); + + Ok(man_page) +} + +fn append_man_section(buf: &mut String, title: &str) { + if !buf.is_empty() { + buf.push('\n'); + } + + buf.push_str(title); + buf.push('\n'); +} + +fn append_man_arguments_section(buf: &mut String, command: &clap::Command) { + let mut section_written = false; + for arg in command.get_positionals() { + if arg.is_hide_set() { + continue; + } + + if !section_written { + append_man_section(buf, "ARGUMENTS"); + section_written = true; + } + + write_man_arg_help(buf, &format_man_value_names(arg), arg); + } +} + +fn append_man_options_section(buf: &mut String, command: &clap::Command) { + let mut section_written = false; + for arg in command.get_opts() { + if arg.is_hide_set() { + continue; + } + + if !section_written { + append_man_section(buf, "OPTIONS"); + section_written = true; + } + + write_man_arg_help(buf, &format_man_option(arg), arg); + } +} + +fn write_man_arg_help(buf: &mut String, label: &str, arg: &clap::Arg) { + buf.push_str(" "); + buf.push_str(label); + buf.push('\n'); + if let Some(help) = arg.get_long_help().or_else(|| arg.get_help()) { + buf.push_str(" "); + buf.push_str(&help.to_string()); + buf.push('\n'); + } +} + +fn format_man_option(arg: &clap::Arg) -> String { + let mut option = String::new(); + if let Some(short) = arg.get_short() { + option.push('-'); + option.push(short); + } + + if let Some(long) = arg.get_long() { + if !option.is_empty() { + option.push_str(", "); + } + + option.push_str("--"); + option.push_str(long); + } + + let values = format_man_value_names(arg); + if !values.is_empty() { + if !option.is_empty() { + option.push(' '); + } + + option.push_str(&values); + } + + option +} + +fn format_man_value_names(arg: &clap::Arg) -> String { + let Some(value_names) = arg.get_value_names() else { + return String::new(); + }; + + let mut values = String::new(); + for name in value_names { + if !values.is_empty() { + values.push(' '); + } + + values.push('<'); + values.push_str(name); + values.push('>'); + } + + values } fn get_builtin_short_description(name: &str, command: &clap::Command) -> String { diff --git a/crates/brush-core-vendored/src/commands.rs b/crates/brush-core-vendored/src/commands.rs index 0cdf8d406..eef2c3244 100644 --- a/crates/brush-core-vendored/src/commands.rs +++ b/crates/brush-core-vendored/src/commands.rs @@ -814,8 +814,19 @@ pub(crate) async fn invoke_shell_function( // Handle control-flow. match result.next_control_flow { - ExecutionControlFlow::BreakLoop { .. } | ExecutionControlFlow::ContinueLoop { .. } => { - return error::unimp("break or continue returned from function invocation"); + ExecutionControlFlow::BreakLoop { .. } => { + writeln!( + context.params.stderr(context.shell), + "break: only meaningful in a `for', `while', or `until' loop" + )?; + result.next_control_flow = ExecutionControlFlow::Normal; + }, + ExecutionControlFlow::ContinueLoop { .. } => { + writeln!( + context.params.stderr(context.shell), + "continue: only meaningful in a `for', `while', or `until' loop" + )?; + result.next_control_flow = ExecutionControlFlow::Normal; }, ExecutionControlFlow::ReturnFromFunctionOrScript => { // It's now been handled. diff --git a/crates/brush-core-vendored/src/error.rs b/crates/brush-core-vendored/src/error.rs index 67b9da42b..9b673d0d9 100644 --- a/crates/brush-core-vendored/src/error.rs +++ b/crates/brush-core-vendored/src/error.rs @@ -29,6 +29,10 @@ pub enum ErrorKind { #[error("cannot assign list to array member")] AssigningListToArrayMember, + /// An attempt was made to assign an associative array value without using a subscript. + #[error("must use subscript when assigning associative array")] + AssociativeArrayMissingSubscript, + /// An attempt was made to convert an associative array to an indexed array. #[error("cannot convert associative array to indexed array")] ConvertingAssociativeArrayToIndexedArray, diff --git a/crates/brush-core-vendored/src/extendedtests.rs b/crates/brush-core-vendored/src/extendedtests.rs index 965397f2e..ec42d1c49 100644 --- a/crates/brush-core-vendored/src/extendedtests.rs +++ b/crates/brush-core-vendored/src/extendedtests.rs @@ -4,7 +4,7 @@ use brush_parser::ast; use crate::{ ExecutionParameters, Shell, ShellFd, arithmetic, env, error, escape, expansion, extensions, - namedoptions, patterns, + namedoptions, patterns, regex, sys::{ fs::{MetadataExt, PathExt}, users, @@ -160,7 +160,13 @@ pub(crate) fn apply_unary_predicate_to_str( Ok(md.gid() == users::get_effective_gid()?) }, ast::UnaryPredicate::FileExistsAndModifiedSinceLastRead => { - error::unimp("unary extended test predicate: FileExistsAndModifiedSinceLastRead") + let path = shell.absolute_path(Path::new(operand)); + if !path.exists() { + return Ok(false); + } + + let md = path.metadata()?; + Ok(md.modified()? > md.accessed()?) }, ast::UnaryPredicate::FileExistsAndOwnedByEffectiveUserId => { let path = shell.absolute_path(Path::new(operand)); @@ -527,7 +533,15 @@ pub(crate) fn apply_binary_predicate_to_strs( }, ast::BinaryPredicate::StringExactlyMatchesString => Ok(left == right), ast::BinaryPredicate::StringDoesNotExactlyMatchString => Ok(left != right), - _ => error::unimp("unsupported test binary predicate"), + ast::BinaryPredicate::StringContainsSubstring => Ok(left.contains(right)), + ast::BinaryPredicate::StringMatchesRegex => { + let re = regex::compile_regex( + right.to_owned(), + shell.options().case_insensitive_conditionals, + true, + )?; + Ok(re.is_match(left)?) + }, } } diff --git a/crates/brush-core-vendored/src/interp.rs b/crates/brush-core-vendored/src/interp.rs index 408182333..5741f789b 100644 --- a/crates/brush-core-vendored/src/interp.rs +++ b/crates/brush-core-vendored/src/interp.rs @@ -1766,7 +1766,7 @@ async fn apply_assignment( existing_value.assign_at_index(array_index, s, assignment.append)?; }, ShellValueLiteral::Array(_) => { - return error::unimp("replacing an array item with an array"); + return Err(error::ErrorKind::AssigningListToArrayMember.into()); }, } } else { @@ -1796,7 +1796,7 @@ async fn apply_assignment( ShellValue::indexed_array_from_literals(ArrayLiteral(vec![(Some(array_index), s)])) }, ShellValueLiteral::Array(_) => { - return error::unimp("cannot assign list to array member"); + return Err(error::ErrorKind::AssigningListToArrayMember.into()); }, } } else { @@ -1917,7 +1917,10 @@ pub(crate) async fn setup_redirect( ast::IoFileRedirectKind::DuplicateInput => 0, ast::IoFileRedirectKind::DuplicateOutput => 1, _ => { - return error::unimp("unexpected redirect kind"); + return Err(error::ErrorKind::InternalError(format!( + "unexpected redirect kind for file descriptor target: {kind:?}" + )) + .into()); }, }; @@ -1937,7 +1940,10 @@ pub(crate) async fn setup_redirect( ast::IoFileRedirectKind::DuplicateInput => 0, ast::IoFileRedirectKind::DuplicateOutput => 1, _ => { - return error::unimp("unexpected redirect kind"); + return Err(error::ErrorKind::InternalError(format!( + "unexpected redirect kind for duplicate target: {kind:?}" + )) + .into()); }, }; @@ -2008,7 +2014,12 @@ pub(crate) async fn setup_redirect( params.open_files.set_fd(fd_num, target_file); }, - _ => return error::unimp("invalid process substitution"), + _ => { + return Err(error::ErrorKind::InternalError(format!( + "process substitution used with invalid redirect kind: {kind:?}" + )) + .into()); + }, } }, } @@ -2113,6 +2124,16 @@ fn setup_process_substitution( let mut child_params = params.clone(); child_params.process_group_policy = ProcessGroupPolicy::SameProcessGroup; + // Starting at 63 (a.k.a. 64-1)--and decrementing--look for an + // available fd before starting the substitution command. + let mut candidate_fd_num = 63; + while params.open_files.contains_fd(candidate_fd_num) { + candidate_fd_num -= 1; + if candidate_fd_num == 0 { + return Err(error::ErrorKind::TooManyOpenFiles.into()); + } + } + // Set up pipe so we can connect to the command. let (reader, writer) = std::io::pipe()?; let (reader, writer) = (reader.into(), writer.into()); @@ -2139,15 +2160,6 @@ fn setup_process_substitution( .await; }); - // Starting at 63 (a.k.a. 64-1)--and decrementing--look for an - // available fd. - let mut candidate_fd_num = 63; - while params.open_files.contains_fd(candidate_fd_num) { - candidate_fd_num -= 1; - if candidate_fd_num == 0 { - return error::unimp("no available file descriptors"); - } - } Ok((candidate_fd_num, target_file)) } diff --git a/crates/brush-core-vendored/src/jobs.rs b/crates/brush-core-vendored/src/jobs.rs index 0a575b23c..bc81dccab 100644 --- a/crates/brush-core-vendored/src/jobs.rs +++ b/crates/brush-core-vendored/src/jobs.rs @@ -1,6 +1,6 @@ //! Job management -use std::{collections::VecDeque, fmt::Display}; +use std::{collections::VecDeque, fmt::Display, time::Duration}; #[cfg(windows)] use std::os::windows::io::OwnedHandle; @@ -12,6 +12,35 @@ use crate::{ExecutionResult, error, processes, sys, trace_categories, traps}; pub(crate) type JobJoinHandle = tokio::task::JoinHandle>; pub(crate) type JobResult = (Job, Result); +const WAIT_NEXT_POLL_INTERVAL: Duration = Duration::from_millis(10); + +/// Selects a managed job by shell job ID or child process ID. +#[derive(Clone, Copy)] +pub enum JobSelector { + /// Shell-internal job ID. + JobId(usize), + /// Child process ID. + ProcessId(i32), +} + +/// Result returned when waiting for a single managed job. +pub struct WaitedJob { + /// Shell-internal job ID. + pub id: usize, + /// Process ID when known, otherwise the shell-internal job ID. + pub identifier: String, + /// Command line associated with the job. + pub command_line: String, + /// Exit status returned by the job. + pub result: ExecutionResult, +} + +impl WaitedJob { + fn from_job(job: Job, result: ExecutionResult, identifier: String) -> Self { + Self { id: job.id, identifier, command_line: job.command_line, result } + } +} + /// Manages the jobs that are currently managed by the shell. #[derive(Default)] pub struct JobManager { @@ -42,17 +71,21 @@ impl JobTask { } /// Waits for the task to complete. Returns the result of the wait. - pub async fn wait(&mut self) -> Result { + pub async fn wait( + &mut self, + wait_for_terminate: bool, + ) -> Result { match self { - Self::External(process) => { + Self::External(process) => loop { let wait_result = process.wait(None).await?; match wait_result { processes::ProcessWaitResult::Completed(output) => { - Ok(JobTaskWaitResult::Completed(output.into())) + break Ok(JobTaskWaitResult::Completed(output.into())); }, - processes::ProcessWaitResult::Stopped => Ok(JobTaskWaitResult::Stopped), + processes::ProcessWaitResult::Stopped if wait_for_terminate => {}, + processes::ProcessWaitResult::Stopped => break Ok(JobTaskWaitResult::Stopped), processes::ProcessWaitResult::Cancelled => { - Ok(JobTaskWaitResult::Completed(ExecutionResult::new(130))) + break Ok(JobTaskWaitResult::Completed(ExecutionResult::new(130))); }, } }, @@ -162,15 +195,110 @@ impl JobManager { } } + /// Tries to resolve the given job specification to a wait selector. + /// + /// # Arguments + /// + /// * `job_spec` - The job specification to resolve. + pub fn resolve_job_spec_selector(&self, job_spec: &str) -> Option { + let remainder = job_spec.strip_prefix('%')?; + + match remainder { + "%" | "+" => self.current_job().map(|job| JobSelector::JobId(job.id)), + "-" => self.prev_job().map(|job| JobSelector::JobId(job.id)), + s if s.chars().all(char::is_numeric) => { + let id = s.parse::().ok()?; + self + .jobs + .iter() + .any(|job| job.id == id) + .then_some(JobSelector::JobId(id)) + }, + _ => { + tracing::warn!(target: trace_categories::UNIMPLEMENTED, "unimplemented: job spec naming command: '{job_spec}'"); + None + }, + } + } + + /// Returns whether a managed job contains the given process ID. + pub fn contains_process_id(&self, pid: i32) -> bool { + self.jobs.iter().any(|job| job.contains_process_id(pid)) + } + + /// Tries to resolve the given process ID to a managed job. + /// + /// # Arguments + /// + /// * `pid` - The process ID to resolve. + pub fn resolve_process_id(&mut self, pid: i32) -> Option<&mut Job> { + self.jobs.iter_mut().find(|job| job.contains_process_id(pid)) + } + /// Waits for all managed jobs to complete. pub async fn wait_all(&mut self) -> Result, error::Error> { + self.wait_all_with_policy(false).await + } + + /// Waits for all managed jobs to terminate, ignoring stopped-state changes. + pub async fn wait_all_for_termination(&mut self) -> Result, error::Error> { + self.wait_all_with_policy(true).await + } + + async fn wait_all_with_policy( + &mut self, + wait_for_terminate: bool, + ) -> Result, error::Error> { for job in &mut self.jobs { - job.wait().await?; + job.wait_with_policy(wait_for_terminate).await?; } Ok(self.sweep_completed_jobs()) } + /// Waits for the next matching managed job to complete. + pub async fn wait_next( + &mut self, + selectors: &[JobSelector], + ) -> Result, error::Error> { + loop { + let mut found_candidate = false; + let mut i = 0; + while i != self.jobs.len() { + if !selectors.is_empty() + && !selectors + .iter() + .any(|selector| self.jobs[i].matches_selector(*selector)) + { + i += 1; + continue; + } + + found_candidate = true; + let identifier = self.jobs[i].wait_identifier(); + if let Some(result) = self.jobs[i].poll_done()? { + let job = self.jobs.remove(i); + return result.map(|result| Some(WaitedJob::from_job(job, result, identifier))); + } + if matches!(self.jobs[i].state, JobState::Done) { + let job = self.jobs.remove(i); + return Ok(Some(WaitedJob::from_job( + job, + ExecutionResult::success(), + identifier, + ))); + } + i += 1; + } + + if !found_candidate { + return Ok(None); + } + + tokio::time::sleep(WAIT_NEXT_POLL_INTERVAL).await; + } + } + /// Polls all managed jobs for completion. pub fn poll(&mut self) -> Result, error::Error> { let mut results = vec![]; @@ -371,10 +499,22 @@ impl Job { /// Waits for the job to complete. pub async fn wait(&mut self) -> Result { + self.wait_with_policy(false).await + } + + /// Waits for the job to terminate, ignoring stopped-state changes. + pub async fn wait_for_termination(&mut self) -> Result { + self.wait_with_policy(true).await + } + + async fn wait_with_policy( + &mut self, + wait_for_terminate: bool, + ) -> Result { let mut result = ExecutionResult::success(); while let Some(task) = self.tasks.back_mut() { - match task.wait().await? { + match task.wait(wait_for_terminate).await? { JobTaskWaitResult::Completed(execution_result) => { result = execution_result; self.tasks.pop_back(); @@ -393,16 +533,17 @@ impl Job { /// Moves the job to execute in the background. pub fn move_to_background(&mut self) -> Result<(), error::Error> { - if matches!(self.state, JobState::Stopped) { - if let Some(pgid) = self.process_group_id() { + match &self.state { + JobState::Stopped => { + let pgid = self + .process_group_id() + .ok_or(error::ErrorKind::FailedToSendSignal)?; sys::signal::continue_process(pgid)?; self.state = JobState::Running; Ok(()) - } else { - Err(error::ErrorKind::FailedToSendSignal.into()) - } - } else { - error::unimp("move job to background") + }, + JobState::Running => Ok(()), + JobState::Unknown | JobState::Done => Err(error::ErrorKind::FailedToSendSignal.into()), } } @@ -457,6 +598,26 @@ impl Job { } } + fn matches_selector(&self, selector: JobSelector) -> bool { + match selector { + JobSelector::JobId(id) => self.id == id, + JobSelector::ProcessId(pid) => self.contains_process_id(pid), + } + } + + fn contains_process_id(&self, pid: i32) -> bool { + self.tasks.iter().any(|task| match task { + JobTask::External(process) => process.pid().is_some_and(|process_pid| process_pid == pid), + JobTask::Internal(_) => false, + }) + } + + fn wait_identifier(&self) -> String { + self + .representative_pid() + .map_or_else(|| self.id.to_string(), |pid| pid.to_string()) + } + /// Tries to retrieve a "representative" pid for the job. pub fn representative_pid(&self) -> Option { for task in &self.tasks { diff --git a/crates/brush-core-vendored/src/prompt.rs b/crates/brush-core-vendored/src/prompt.rs index 8dff6972d..06d4f32a2 100644 --- a/crates/brush-core-vendored/src/prompt.rs +++ b/crates/brush-core-vendored/src/prompt.rs @@ -71,7 +71,7 @@ fn format_prompt_piece( return error::unimp("prompt: current command number"); }, brush_parser::prompt::PromptPiece::CurrentHistoryNumber => { - return error::unimp("prompt: current history number"); + format_current_history_number(shell) }, brush_parser::prompt::PromptPiece::CurrentUser => users::get_current_username()?, brush_parser::prompt::PromptPiece::CurrentWorkingDirectory { tilde_replaced, basename } => { @@ -171,6 +171,13 @@ fn format_current_working_directory( working_dir_str } +fn format_current_history_number(shell: &Shell) -> String { + // Bash renders \! as the history number that will be assigned to the next + // interactive command. When command history is disabled, bash keeps this at + // 1 rather than rendering 0. + shell.history().map_or(1, |history| history.count() + 1).to_string() +} + fn format_time( datetime: &chrono::DateTime, format: &brush_parser::prompt::PromptTimeFormat, diff --git a/crates/brush-core-vendored/src/shell/funcs.rs b/crates/brush-core-vendored/src/shell/funcs.rs index 063199bd6..5f23b438b 100644 --- a/crates/brush-core-vendored/src/shell/funcs.rs +++ b/crates/brush-core-vendored/src/shell/funcs.rs @@ -1,7 +1,10 @@ //! Function support for shells. +use std::io::Write; + use crate::{ - ExecutionParameters, commands, error, extensions, functions, results::ExecutionWaitResult, + ExecutionParameters, commands, error, extensions, functions, jobs, + results::{ExecutionResult, ExecutionWaitResult}, }; impl crate::Shell { @@ -117,7 +120,20 @@ impl crate::Shell { match result.wait_with_cancel(params.cancel_token()).await? { ExecutionWaitResult::Completed(result) => Ok(result.exit_code.into()), - ExecutionWaitResult::Stopped(..) => error::unimp("stopped child from function invocation"), + ExecutionWaitResult::Stopped(child) => { + let result = ExecutionResult::stopped(); + let job = self.jobs_mut().add_as_current(jobs::Job::new( + [jobs::JobTask::External(child)], + name.to_owned(), + jobs::JobState::Stopped, + )); + let formatted = job.to_string(); + + // N.B. We use the '\r' to overwrite any ^Z output. + writeln!(params.stderr(self), "\r{formatted}")?; + + Ok(result.exit_code.into()) + }, } } } diff --git a/crates/brush-core-vendored/src/shell/initscripts.rs b/crates/brush-core-vendored/src/shell/initscripts.rs index a30e16d21..31f26fd5b 100644 --- a/crates/brush-core-vendored/src/shell/initscripts.rs +++ b/crates/brush-core-vendored/src/shell/initscripts.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; -use crate::{Shell, error, extensions, interp}; +use crate::{Shell, error, expansion, extensions, interp}; /// Behavior for loading profile files. #[derive(Default)] @@ -130,14 +130,24 @@ impl Shell { "BASH_ENV" }; - if self.env.is_set(env_var_name) { - // - // TODO(well-known-vars): look at $ENV/BASH_ENV; source its expansion if that - // file exists - // - return error::unimp( - "load config from $ENV/BASH_ENV for non-interactive, non-login shell", - ); + if let Some(config_path) = self.env_str(env_var_name) { + let config_path = config_path.into_owned(); + let options = expansion::ExpanderOptions { + brace_expand: false, + pathname_expand: false, + ..Default::default() + }; + let expanded_path = expansion::basic_expand_word_with_options( + self, + ¶ms, + config_path.as_str(), + &options, + ) + .await?; + + if !expanded_path.is_empty() { + self.source_if_exists(PathBuf::from(expanded_path), ¶ms).await?; + } } } } diff --git a/crates/brush-core-vendored/src/variables.rs b/crates/brush-core-vendored/src/variables.rs index a89c2409b..ea839f89a 100644 --- a/crates/brush-core-vendored/src/variables.rs +++ b/crates/brush-core-vendored/src/variables.rs @@ -437,10 +437,8 @@ impl ShellVariable { } Ok(()) }, - _ => { - tracing::error!("assigning to index {array_index} of {:?}", self.value); - error::unimp("assigning to index of non-array variable") - }, + ShellValue::Dynamic { .. } => Ok(()), + ShellValue::Unset(_) | ShellValue::String(_) => Err(error::ErrorKind::NotArray.into()), } } @@ -800,23 +798,45 @@ impl ShellValue { existing_values: &mut BTreeMap, literal_values: ArrayLiteral, ) -> Result<(), error::Error> { - let mut current_key = None; - for (key, value) in literal_values.0 { - if let Some(current_key) = current_key.take() { - if key.is_some() { - return error::unimp("misaligned keys/values in associative array literal"); - } else { - existing_values.insert(current_key, value); - } - } else if let Some(key) = key { - existing_values.insert(key, value); - } else { - current_key = Some(value); - } - } + let mut literal_values = literal_values.0.into_iter(); + let Some((first_key, first_value)) = literal_values.next() else { + return Ok(()); + }; - if let Some(current_key) = current_key { - existing_values.insert(current_key, String::new()); + if let Some(first_key) = first_key { + existing_values.insert(first_key, first_value); + + for (key, value) in literal_values { + let Some(key) = key else { + return Err(error::ErrorKind::AssociativeArrayMissingSubscript.into()); + }; + + existing_values.insert(key, value); + } + } else { + let mut current_key = Some(first_value); + for (key, value) in literal_values { + let value = if let Some(key) = key { + let mut word = String::with_capacity(key.len() + value.len() + 3); + word.push('['); + word.push_str(key.as_str()); + word.push_str("]="); + word.push_str(value.as_str()); + word + } else { + value + }; + + if let Some(key) = current_key.take() { + existing_values.insert(key, value); + } else { + current_key = Some(value); + } + } + + if let Some(current_key) = current_key { + existing_values.insert(current_key, String::new()); + } } Ok(()) diff --git a/crates/pi-ast/src/block.rs b/crates/pi-ast/src/block.rs index 567e9f95f..fd551d9a6 100644 --- a/crates/pi-ast/src/block.rs +++ b/crates/pi-ast/src/block.rs @@ -8,10 +8,12 @@ //! full span; pointing at a continuation line or a lone closing delimiter //! resolves to nothing. +use std::collections::BTreeSet; + use anyhow::{Result, anyhow}; use ast_grep_core::tree_sitter::LanguageExt; use serde::{Deserialize, Serialize}; -use tree_sitter::{Parser, Point}; +use tree_sitter::{Parser, Point, TreeCursor}; use crate::summary::{node_content_end_line, node_start_line, resolve_language}; @@ -111,6 +113,141 @@ pub fn block_range_at(options: BlockRangeOptions) -> Result> })) } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub struct LineRange { + /// 1-indexed inclusive first visible line. + pub start_line: u32, + /// 1-indexed inclusive last visible line. + pub end_line: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnclosingBoundaryOptions { + /// Source code to inspect. + pub code: String, + /// Language alias (e.g. "rust", "typescript") used before path inference. + pub lang: Option, + /// File path used to infer language by extension when `lang` is omitted. + pub path: Option, + /// 1-indexed inclusive visible line ranges (the lines actually shown). + pub ranges: Vec, +} + +/// Sort, drop invalid, and merge adjacent/overlapping ranges so visibility +/// tests can binary-search a non-overlapping list. +fn normalize_ranges(mut ranges: Vec) -> Vec { + ranges.retain(|range| range.start_line > 0 && range.end_line >= range.start_line); + ranges.sort_by(|a, b| { + a.start_line + .cmp(&b.start_line) + .then(a.end_line.cmp(&b.end_line)) + }); + let mut merged: Vec = Vec::with_capacity(ranges.len()); + for range in ranges { + if let Some(last) = merged.last_mut() + && range.start_line <= last.end_line.saturating_add(1) + { + last.end_line = last.end_line.max(range.end_line); + continue; + } + merged.push(range); + } + merged +} + +fn is_visible(merged: &[LineRange], line: u32) -> bool { + merged + .binary_search_by(|range| { + if line < range.start_line { + std::cmp::Ordering::Greater + } else if line > range.end_line { + std::cmp::Ordering::Less + } else { + std::cmp::Ordering::Equal + } + }) + .is_ok() +} + +/// Depth-first walk collecting boundary lines from every multi-line named node +/// that straddles a visible-range edge. A single reused [`TreeCursor`] keeps +/// the traversal allocation-free. +fn collect_boundaries(cursor: &mut TreeCursor<'_>, merged: &[LineRange], out: &mut BTreeSet) { + let node = cursor.node(); + // Skip the whole-file root: its only "boundary" is EOF, never a useful + // matching line (mirrors `block_range_at` excluding the root). + if node.is_named() && node.parent().is_some() { + let start = node_start_line(node); + let end = node_content_end_line(node); + if end > start { + let start_visible = is_visible(merged, start); + let end_visible = is_visible(merged, end); + // Opener shown, closer off-window → surface the closer (and vice + // versa). A node fully inside or fully outside the window adds + // nothing. + if start_visible && !end_visible { + out.insert(end); + } else if end_visible && !start_visible { + out.insert(start); + } + } + } + if cursor.goto_first_child() { + loop { + collect_boundaries(cursor, merged, out); + if !cursor.goto_next_sibling() { + break; + } + } + cursor.goto_parent(); + } +} + +/// Generalize "show the matching bracket" to every tree-sitter block: for each +/// multi-line named node whose span crosses the visible window, return the +/// boundary line sitting *outside* that window. +/// +/// - node opens on a visible line but closes past the window → its closing line +/// - node closes on a visible line but opens before the window → its opening +/// line +/// +/// Because the trigger is an endpoint *inside* the window, the result is +/// bounded by the window size (not nesting depth), exactly like a bracket scan +/// — but it also covers indentation languages (Python) and uses real syntactic +/// spans. +/// +/// Returns `None` when the language is unrecognized or the source fails to +/// parse / carries a syntax error (caller falls back to a lexical bracket +/// scan); `Some(sorted unique boundary lines)` otherwise (possibly empty). +pub fn enclosing_block_boundaries(options: EnclosingBoundaryOptions) -> Result>> { + let EnclosingBoundaryOptions { code, lang, path, ranges } = options; + let merged = normalize_ranges(ranges); + if code.is_empty() || merged.is_empty() { + return Ok(Some(Vec::new())); + } + let Some(language) = resolve_language(lang.as_deref(), path.as_deref()) else { + return Ok(None); + }; + let mut parser = Parser::new(); + parser + .set_language(&language.get_ts_language()) + .map_err(|err| anyhow!("Failed to load tree-sitter language: {err}"))?; + let Some(tree) = parser.parse(&code, None) else { + return Ok(None); + }; + let root = tree.root_node(); + // A file-level syntax error makes error-recovery spans unreliable; defer to + // the lexical scanner rather than emit boundaries off a broken tree. + if root.has_error() { + return Ok(None); + } + + let mut boundaries = BTreeSet::new(); + let mut cursor = root.walk(); + collect_boundaries(&mut cursor, &merged, &mut boundaries); + Ok(Some(boundaries.into_iter().collect())) +} + #[cfg(test)] mod tests { use super::*; @@ -220,4 +357,63 @@ mod tests { let code = "struct A;\nstruct B {\n x: u32,\n}\n"; assert_eq!(resolve(code, "r.rs", 2), Some(BlockRange { start_line: 2, end_line: 4 })); } + + fn boundaries(code: &str, path: &str, ranges: &[(u32, u32)]) -> Option> { + enclosing_block_boundaries(EnclosingBoundaryOptions { + code: code.to_string(), + lang: None, + path: Some(path.to_string()), + ranges: ranges + .iter() + .map(|&(start_line, end_line)| LineRange { start_line, end_line }) + .collect(), + }) + .expect("boundary resolution succeeds") + } + + const TS_FN: &str = "function outer() {\n const a = 1;\n const b = 2;\n const c = 3;\n \ + return a + b + c;\n}\nafter();\n"; + + #[test] + fn surfaces_closing_brace_for_visible_opener() { + // Window is the opening line only; its block closes on line 6. + assert_eq!(boundaries(TS_FN, "x.ts", &[(1, 1)]), Some(vec![6])); + } + + #[test] + fn surfaces_opening_brace_for_visible_closer() { + // Window is the closing line only; its block opens on line 1. + assert_eq!(boundaries(TS_FN, "x.ts", &[(6, 6)]), Some(vec![1])); + } + + #[test] + fn interior_only_window_adds_no_boundary() { + // Neither the opener (1) nor the closer (6) is visible, so the bracket + // scan would add nothing — and neither do we. + assert_eq!(boundaries(TS_FN, "x.ts", &[(3, 4)]), Some(vec![])); + } + + #[test] + fn whole_file_window_adds_no_boundary() { + assert_eq!(boundaries(TS_FN, "x.ts", &[(1, 7)]), Some(vec![])); + } + + #[test] + fn python_indentation_block_uses_syntactic_span() { + // Python has no closing delimiter — the def's span ends at the last + // body line. Showing the `def` header surfaces that end line. + let code = "def greet(name):\n a = 1\n b = 2\n return a + b\n"; + assert_eq!(boundaries(code, "g.py", &[(1, 1)]), Some(vec![4])); + } + + #[test] + fn syntax_error_falls_back_to_none() { + let code = "function broken() {\n if (y) {\n"; + assert_eq!(boundaries(code, "b.ts", &[(1, 1)]), None); + } + + #[test] + fn unrecognized_language_falls_back_to_none() { + assert_eq!(boundaries(TS_FN, "x.unknownext", &[(1, 1)]), None); + } } diff --git a/crates/pi-natives/src/ast.rs b/crates/pi-natives/src/ast.rs index c62212c7e..ccde8daa1 100644 --- a/crates/pi-natives/src/ast.rs +++ b/crates/pi-natives/src/ast.rs @@ -128,6 +128,45 @@ pub struct AstFindResult { pub parse_errors: Option>, } +/// Options for `astMatch`: run ast-grep patterns against an in-memory source +/// string instead of files on disk. +#[napi(object)] +pub struct AstMatchOptions<'env> { + /// Source code to match against (parsed in memory, never read from disk). + pub source: String, + /// Language of `source` (required; e.g. "ts", "tsx", "rust", "python"). + pub lang: String, + /// ast-grep patterns to search for (OR across patterns). + pub patterns: Vec, + /// Rule selector for multi-rule ast-grep configurations. + pub selector: Option, + /// Pattern strictness; defaults to smart matching when omitted. + pub strictness: Option, + /// Maximum matches to return after `offset` (default applies when omitted). + pub limit: Option, + /// Number of leading matches to skip before applying `limit`. + pub offset: Option, + /// When true, include meta-variable bindings per match. + pub include_meta: Option, + /// Optional cancellation handle (library-specific). + pub signal: Option>, + /// Wall-clock timeout for the worker task in milliseconds. + pub timeout_ms: Option, +} + +/// Result of an in-memory `astMatch` run. +#[napi(object)] +pub struct AstMatchResult { + /// Page of matches after sort, offset, and limit. + pub matches: Vec, + /// Total matches found before paging (can exceed `matches.length`). + pub total_matches: u32, + /// True when results were truncated by `limit`. + pub limit_reached: bool, + /// Non-fatal parse or pattern-compile errors collected during the run. + pub parse_errors: Option>, +} + /// Options for `astEdit`: rewrite rules, scan scope, safety limits, and /// dry-run. #[napi(object)] @@ -688,6 +727,117 @@ pub fn ast_grep(options: AstFindOptions<'_>) -> task::Promise { }) } +/// Match ast-grep patterns against an in-memory source string; returns a +/// promise resolved on a worker thread. +/// +/// This is the file-free counterpart to [`ast_grep`]: callers that already hold +/// the source (streaming buffers, generated code, editor contents) avoid a +/// temp-file round trip. `lang` is required since there is no path to infer it +/// from. +#[napi] +pub fn ast_match(options: AstMatchOptions<'_>) -> task::Promise { + let AstMatchOptions { + source, + lang, + patterns, + selector, + strictness, + limit, + offset, + include_meta, + signal, + timeout_ms, + } = options; + + let ct = task::CancelToken::new(timeout_ms, signal); + let normalized_limit = limit.unwrap_or(DEFAULT_FIND_LIMIT).max(1); + let normalized_offset = offset.unwrap_or(0); + + task::blocking("ast_match", ct, move |ct| { + let patterns = normalize_pattern_list(Some(patterns))?; + let strictness = resolve_strictness(strictness); + let include_meta = include_meta.unwrap_or(false); + let lang_str = lang.trim(); + if lang_str.is_empty() { + return Err(Error::from_reason("`lang` is required for ast_match".to_string())); + } + let language = resolve_supported_lang(lang_str)?; + + let mut parse_errors = Vec::new(); + let mut compiled_patterns = Vec::with_capacity(patterns.len()); + for pattern in &patterns { + ct.heartbeat()?; + match compile_pattern(pattern, selector.as_deref(), &strictness, language) { + Ok(compiled) => compiled_patterns.push(compiled), + Err(err) => parse_errors.push(format!("{pattern}: {err}")), + } + } + + let mut all_matches = Vec::new(); + let mut total_matches = 0u32; + if !compiled_patterns.is_empty() { + let ast = language.ast_grep(&source); + if ast.root().dfs().any(|node| node.is_error()) { + parse_errors.push("parse error (syntax tree contains error nodes)".to_string()); + } + for pattern in &compiled_patterns { + ct.heartbeat()?; + for matched in ast.root().find_all(pattern.clone()) { + ct.heartbeat()?; + total_matches = total_matches.saturating_add(1); + let range = matched.range(); + let start = matched.start_pos(); + let end = matched.end_pos(); + let meta_variables = if include_meta { + Some(HashMap::::from(matched.get_env().clone())) + } else { + None + }; + all_matches.push(AstFindMatch { + path: String::new(), + text: matched.text().into_owned(), + byte_start: to_u32(range.start), + byte_end: to_u32(range.end), + start_line: to_u32(start.line().saturating_add(1)), + start_column: to_u32(start.column(matched.get_node()).saturating_add(1)), + end_line: to_u32(end.line().saturating_add(1)), + end_column: to_u32(end.column(matched.get_node()).saturating_add(1)), + meta_variables, + }); + } + } + } + + all_matches.sort_by(|left, right| { + left + .start_line + .cmp(&right.start_line) + .then(left.start_column.cmp(&right.start_column)) + .then(left.end_line.cmp(&right.end_line)) + .then(left.end_column.cmp(&right.end_column)) + .then(left.byte_start.cmp(&right.byte_start)) + .then(left.byte_end.cmp(&right.byte_end)) + }); + + let visible_matches = all_matches + .into_iter() + .skip(normalized_offset as usize) + .collect::>(); + let limit_reached = visible_matches.len() > normalized_limit as usize; + let matches = visible_matches + .into_iter() + .take(normalized_limit as usize) + .collect::>(); + + Ok(AstMatchResult { + matches, + total_matches, + limit_reached, + parse_errors: (!parse_errors.is_empty()).then_some(parse_errors), + }) + }) +} + /// Apply ast-grep rewrite rules to matching files; honors `dryRun` and returns /// a promise. #[napi] diff --git a/crates/pi-natives/src/block.rs b/crates/pi-natives/src/block.rs index 88d942693..4bdd14e59 100644 --- a/crates/pi-natives/src/block.rs +++ b/crates/pi-natives/src/block.rs @@ -45,3 +45,51 @@ pub fn block_range_at(options: BlockRangeOptions) -> Result> .map(|range| range.map(Into::into)) .map_err(|error| Error::from_reason(error.to_string())) } + +#[napi(object)] +pub struct LineRange { + /// 1-indexed inclusive first visible line. + pub start_line: u32, + /// 1-indexed inclusive last visible line. + pub end_line: u32, +} + +#[napi(object)] +pub struct EnclosingBoundaryOptions { + /// Source code to inspect. + pub code: String, + /// Language alias (e.g. "rust", "typescript") used before path inference. + pub lang: Option, + /// File path used to infer language by extension when `lang` is omitted. + pub path: Option, + /// 1-indexed inclusive visible line ranges (the lines actually shown). + pub ranges: Vec, +} + +/// Matching-bracket context for an arbitrary tree-sitter language. +/// +/// For each multi-line named node whose span crosses the visible window, return +/// the boundary line sitting *outside* that window (the closer when the opener +/// is shown, the opener when the closer is shown). Covers brace and indentation +/// languages alike using real syntactic spans. +/// +/// Returns `null` when the language is unrecognized or the source fails to +/// parse / carries a syntax error (caller should fall back to a lexical scan); +/// a sorted, unique list of 1-indexed boundary lines otherwise. +#[napi] +pub fn enclosing_block_boundaries(options: EnclosingBoundaryOptions) -> Result>> { + pi_ast::block::enclosing_block_boundaries(pi_ast::block::EnclosingBoundaryOptions { + code: options.code, + lang: options.lang, + path: options.path, + ranges: options + .ranges + .into_iter() + .map(|range| pi_ast::block::LineRange { + start_line: range.start_line, + end_line: range.end_line, + }) + .collect(), + }) + .map_err(|error| Error::from_reason(error.to_string())) +} diff --git a/crates/pi-natives/src/lib.rs b/crates/pi-natives/src/lib.rs index fc890e793..ec2e406c3 100644 --- a/crates/pi-natives/src/lib.rs +++ b/crates/pi-natives/src/lib.rs @@ -68,5 +68,5 @@ use napi_derive::napi; /// 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 = "__piNativesV15_10_4")] +#[napi(js_name = "__piNativesV15_10_5")] pub const fn pi_natives_version_sentinel() {} diff --git a/crates/pi-shell/src/shell.rs b/crates/pi-shell/src/shell.rs index 4de375c1c..111ad77dd 100644 --- a/crates/pi-shell/src/shell.rs +++ b/crates/pi-shell/src/shell.rs @@ -490,6 +490,7 @@ async fn create_session(config: &ShellConfig) -> Result { } shell.register_builtin("sleep", builtins::builtin::()); shell.register_builtin("timeout", builtins::builtin::()); + shell.register_builtin("nohup", builtins::builtin::()); let mut merged_path: Option = None; for (key, value) in std::env::vars() { @@ -1573,6 +1574,61 @@ impl builtins::Command for TimeoutCommand { } } } + +#[derive(Parser)] +#[command(disable_help_flag = true)] +struct NohupCommand { + #[arg(num_args = 0.., trailing_var_arg = true, allow_hyphen_values = true)] + command: Vec, +} + +impl builtins::Command for NohupCommand { + type Error = brush_core::Error; + + fn execute( + &self, + context: ExecutionContext<'_, SE>, + ) -> impl Future> + Send { + let command = self.command.clone(); + async move { + if context.is_cancelled() { + return Ok(ExecutionExitCode::Interrupted.into()); + } + // coreutils `nohup` with no operand fails with exit code 125. + if command.is_empty() { + let _ = writeln!(context.stderr(), "nohup: missing operand"); + return Ok(ExecutionResult::new(125)); + } + + // Deliberately *not* nohup: we neither ignore SIGHUP nor detach the + // child into a new session. The command runs as an ordinary brush + // descendant so it is reaped together with the host instead of + // lingering as an orphan once the host process goes away. Agents + // reach for `nohup` assuming the shell is one-shot; in this + // persistent embedded shell that assumption is wrong and the only + // effect of real `nohup` would be to leak background processes. + // + // coreutils `nohup` additionally redirects stdin from /dev/null and + // stdout/stderr to `nohup.out`, but *only* when those streams are + // terminals. The embedded host always hands commands a pipe with a + // /dev/null stdin, so none of that redirection ever applies here. + let mut command_line = String::new(); + for (idx, arg) in command.iter().enumerate() { + if idx > 0 { + command_line.push(' '); + } + command_line.push_str("e_arg(arg)); + } + + let params = context.params.clone(); + let source_info = SourceInfo::from("pi-natives:nohup"); + context + .shell + .run_string(command_line, &source_info, ¶ms) + .await + } + } +} fn parse_duration(input: &str) -> Option { let trimmed = input.trim(); if trimmed.is_empty() { @@ -1918,6 +1974,57 @@ mod tests { ); } + #[tokio::test(flavor = "multi_thread")] + async fn wait_accepts_last_background_process_id() { + let options = ShellExecuteOptions { + command: "/bin/sh -c 'exit 7' & mover=$!; wait \"$mover\"".to_string(), + ..Default::default() + }; + + let result = execute_shell(options, None, CancelToken::default()) + .await + .expect("execute should succeed"); + + assert_eq!(result.exit_code, Some(7)); + assert!(!result.cancelled); + assert!(!result.timed_out); + } + + #[tokio::test(flavor = "multi_thread")] + async fn wait_n_p_records_completed_process_id() { + let options = ShellExecuteOptions { + command: "/bin/sh -c 'sleep 0.2; exit 42' & slow=$!; /bin/sh -c 'exit 13' & fast=$!; \ + wait -n -p hit \"$slow\" \"$fast\"; status=$?; wait \"$slow\"; [ \"$status\" \ + -eq 13 ] && [ \"$hit\" = \"$fast\" ]" + .to_string(), + ..Default::default() + }; + + let result = execute_shell(options, None, CancelToken::default()) + .await + .expect("execute should succeed"); + + assert_eq!(result.exit_code, Some(0)); + assert!(!result.cancelled); + assert!(!result.timed_out); + } + + #[tokio::test(flavor = "multi_thread")] + async fn wait_f_accepts_process_id() { + let options = ShellExecuteOptions { + command: "/bin/sh -c 'exit 5' & child=$!; wait -f \"$child\"".to_string(), + ..Default::default() + }; + + let result = execute_shell(options, None, CancelToken::default()) + .await + .expect("execute should succeed"); + + assert_eq!(result.exit_code, Some(5)); + assert!(!result.cancelled); + assert!(!result.timed_out); + } + #[tokio::test] async fn abort_state_signals_cancel_token() { let abort_state = ShellAbortState::default(); @@ -2129,4 +2236,84 @@ mod tests { assert_eq!(result.exit_code, Some(0), "command did not run to completion"); } + + /// The `nohup` builtin runs its operand command and surfaces that command's + /// own exit status — not nohup's (`125`/`126`/`127`) error codes. + #[tokio::test(flavor = "multi_thread")] + async fn nohup_builtin_propagates_command_exit_code() { + let options = ShellExecuteOptions { + command: "nohup /bin/sh -c 'exit 7'".to_string(), + ..Default::default() + }; + let result = execute_shell(options, None, CancelToken::default()) + .await + .expect("execute should succeed"); + assert_eq!(result.exit_code, Some(7)); + assert!(!result.cancelled); + assert!(!result.timed_out); + } + + /// `nohup` with no operand mirrors coreutils: a `missing operand` diagnostic + /// and exit code 125 (a nohup-level error, distinct from any command code). + #[tokio::test(flavor = "multi_thread")] + async fn nohup_builtin_without_command_reports_missing_operand() { + let (tx, mut rx) = mpsc::unbounded_channel::(); + let options = ShellExecuteOptions { command: "nohup".to_string(), ..Default::default() }; + let result = execute_shell(options, Some(tx), CancelToken::default()) + .await + .expect("execute should succeed"); + assert_eq!(result.exit_code, Some(125)); + let mut out = String::new(); + while let Some(chunk) = rx.recv().await { + out.push_str(&chunk); + } + assert!( + out.contains("missing operand"), + "expected a missing-operand diagnostic, got: {out:?}" + ); + } + + /// The contract that makes this a *builtin* and not the external tool: the + /// child must **not** inherit `SIGHUP = SIG_IGN`. Real `nohup` masks SIGHUP + /// (and it survives `exec`), so a process launched through `/usr/bin/nohup` + /// reports `IGN` here; the builtin runs the command as an ordinary + /// descendant, so it reports `DFL` and dies with the host on hangup. The + /// probe needs `getsid`-style signal introspection, so it is gated on + /// `python3` (skipped, not failed, when absent — matching the embedded + /// session-detach e2e suite). + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread")] + async fn nohup_builtin_does_not_mask_sighup() { + let python_ok = std::process::Command::new("python3") + .arg("-c") + .arg("pass") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|status| status.success()); + if !python_ok { + eprintln!("skipping nohup_builtin_does_not_mask_sighup: python3 unavailable"); + return; + } + + let probe = "import signal,sys; sys.stdout.write('IGN' if \ + signal.getsignal(signal.SIGHUP)==signal.SIG_IGN else 'DFL')"; + let (tx, mut rx) = mpsc::unbounded_channel::(); + let options = ShellExecuteOptions { + command: format!("nohup python3 -c \"{probe}\""), + ..Default::default() + }; + let result = execute_shell(options, Some(tx), CancelToken::default()) + .await + .expect("execute should succeed"); + assert_eq!(result.exit_code, Some(0)); + let mut out = String::new(); + while let Some(chunk) = rx.recv().await { + out.push_str(&chunk); + } + assert!( + out.contains("DFL") && !out.contains("IGN"), + "builtin nohup masked SIGHUP like the external tool (output: {out:?})", + ); + } } diff --git a/docs/adding-a-provider.md b/docs/adding-a-provider.md new file mode 100644 index 000000000..55bddd39e --- /dev/null +++ b/docs/adding-a-provider.md @@ -0,0 +1,85 @@ +# Adding a provider + +Providers in `packages/ai` are described by a single declarative +`ProviderDefinition` and collected in one registry. Every scattered structure — +the `KnownProvider` / `OAuthProvider` type unions, `PROVIDER_DESCRIPTORS`, +`DEFAULT_MODEL_PER_PROVIDER`, the `serviceProviderMap` env-key fallbacks, the +`/login` provider list, the `refreshOAuthToken` / `AuthStorage.login` dispatch, +and the coding-agent callback maps — is **derived** from that registry. + +**Scope.** This is for a provider that reuses an existing wire API +(`openai-completions`, `anthropic-messages`, `google-generative-ai`, …) — the +common case for gateways and API-key providers, since stream dispatch keys on +`model.api`, not `model.provider`. Adding a *new wire protocol* (a new +`KnownApi`) is a separate task that also touches `stream.ts` dispatch, +`api-registry.ts`, and `types.ts`. + +## Shape + +For the common case, a provider is still **one new def file + one registry line**: + +1. **Create `packages/ai/src/registry/.ts`** exporting one + `export const Provider = { … } as const satisfies ProviderDefinition;`. +2. **Add it to the `ALL` array** in `packages/ai/src/registry/registry.ts` + (one import + one array entry). `ALL` order is the `/login` list order for + loginable providers. + +That is the full change for: +- env-key-only providers, +- providers with a simple inline API-key login flow, +- most OpenAI-compatible gateways. + +For a **non-trivial provider-local OAuth flow**, put the implementation in +`packages/ai/src/registry/oauth/.ts` and lazy-import it from the def +file. The shared OAuth flow infrastructure it builds on lives in the same +`registry/oauth/` directory. + +Either way, descriptors, default-model map, env-key map, login list, and refresh +dispatch all update automatically, and the `KnownProvider` / `OAuthProvider` +unions gain the new id by derivation. + +## `ProviderDefinition` fields + +See `packages/ai/src/registry/types.ts` for the authoritative, +JSDoc-annotated interface. Presence of a field opts the provider into a derived +structure: + +| Field | Effect | +|---|---| +| `id`, `name` | Required. `name` shows in the `/login` list. | +| `defaultModel` | Present ⇒ member of `KnownProvider` (a chat-model provider). | +| `createModelManagerOptions` | Runtime model-discovery factory. Present (and not `specialModelManager`) ⇒ appears in `PROVIDER_DESCRIPTORS`. | +| `allowUnauthenticated` | Runtime creates a model manager even without a key. | +| `dynamicModelsAuthoritative` | Successful discovery replaces bundled models. | +| `catalogDiscovery` | `{ label, envVars, oauthProvider?, allowUnauthenticated? }` for offline catalog generation (`generate-models.ts`). | +| `specialModelManager` | Bespoke runtime factory (`google-antigravity` / `google-gemini-cli` / `openai-codex`); excluded from `PROVIDER_DESCRIPTORS`. | +| `envKeys` | Env-var fallback for `getEnvApiKey`: a var name string or a `() => string \| undefined` resolver. | +| `login` | Interactive login. Present ⇒ member of `OAuthProvider`, shown in `/login`, dispatchable via `AuthStorage.login`. Returns an api-key `string` or `OAuthCredentials`. | +| `refreshToken` | OAuth refresher; omit for static-token providers (the dispatch returns credentials unchanged). | +| `storeCredentialsAs` | Store credentials under a different provider id (e.g. `openai-codex-device` ⇒ `openai-codex`). | +| `callbackPort` | Present ⇒ entry in the auth-broker `CALLBACK_PORTS` map. | +| `pasteCodeFlow` | OAuth flow needs a pasted code/redirect URL ⇒ member of `PASTE_CODE_LOGIN_PROVIDERS`. | + +## Conventions + +- Use `... as const satisfies ProviderDefinition` so the literal `id` is preserved + for the union derivation. +- `login` / `refreshToken` for simple API-key or validation-based flows can live + directly in the provider def file (export the named login function there so + tests can import it directly). +- `login` / `refreshToken` for heavy provider-local OAuth flows MUST reach the + adjacent `registry/oauth/*` module via a dynamic-import + thunk (`const { loginX } = await import("./oauth/x"); return loginX(cb);`), + keeping those flows out of the eager startup graph. +- All OAuth code lives under `registry/oauth/`: the shared flow infra + (`callback-server`, `pkce`, `google-oauth-shared`, `types`, the runtime API + `index`) plus every provider flow, including the `github-copilot` / `kimi` / + `openai-codex` helpers reused by the streaming and usage layers. The non-OAuth + API-key helpers (`api-key-login`, `api-key-validation`) sit beside the def + files in `registry/`, since they back simple paste-an-API-key logins. +- For a simple OpenAI-compatible gateway, build the manager inline with the + exported `createSimpleOpenAICompletionsOptions(providerId, baseUrl, config)` — + no edits to `openai-compat.ts` required. +- A `ProviderDefinition` may also be registered at runtime by an extension via + `registerOAuthProvider` (the `AuthStorage.login` dispatcher handles built-ins + and extensions through the same path). diff --git a/docs/python-repl.md b/docs/python-repl.md index 40f246a8c..710321f35 100644 --- a/docs/python-repl.md +++ b/docs/python-repl.md @@ -27,7 +27,7 @@ Tool params: language: "py" | "js"; code: string; title?: string; - timeout?: number; // seconds, clamped to 1..600, default 30. Inactivity budget — see "Cell timeout". + timeout?: number; // seconds, clamped to 1..3600, default 30. Inactivity budget — see "Cell timeout". reset?: boolean; // reset this cell's selected runtime before execution }>; } @@ -232,7 +232,7 @@ Output is streamed through `OutputSink` and may be persisted to artifact storage - **Python backend not available** — Check `eval.py`, `PI_PY`, and that `python`/`python3` is on PATH. If preflight fails and `eval.js` is enabled, use a `js` cell. - **No Python on PATH** — Install a system Python 3.8+ or place a venv at `~/.omp/python-env`. `omp setup python --check` reports the resolved interpreter. -- **Execution hangs then times out** — Increase tool `timeout` (max 600s) if workload is legitimate. For stuck native code, cancellation triggers `SIGINT` first then escalates; the session restarts on the next request. +- **Execution hangs then times out** — Increase tool `timeout` (max 3600s) if workload is legitimate. For stuck native code, cancellation triggers `SIGINT` first then escalates; the session restarts on the next request. - **stdin/input prompts in Python code** — `input()` is not supported; pass data programmatically. - **Working directory errors** — Tool validates `cwd` exists and is a directory before execution. diff --git a/docs/rulebook-matching-pipeline.md b/docs/rulebook-matching-pipeline.md index edf7889dd..a0e930781 100644 --- a/docs/rulebook-matching-pipeline.md +++ b/docs/rulebook-matching-pipeline.md @@ -38,6 +38,7 @@ interface Rule { alwaysApply?: boolean; description?: string; condition?: string[]; + astCondition?: string[]; scope?: string[]; interruptMode?: "never" | "prose-only" | "tool-only" | "always"; _source: SourceMeta; @@ -183,16 +184,16 @@ After rule discovery in `createAgentSession` (`sdk.ts`), `bucketRules(...)` appl 1. Drop rules listed in `ttsr.disabledRules`. 2. Drop rules from the `builtin-defaults` provider when `ttsr.builtinRules === false`. -3. Register rules with non-empty `condition` into `TtsrManager`; if registration succeeds, the rule is TTSR-only. +3. Register rules with a non-empty `condition` or `astCondition` into `TtsrManager`; if registration succeeds, the rule is TTSR-only. 4. Put remaining `alwaysApply === true` rules into `alwaysApplyRules`. 5. Put remaining rules with `description` into `rulebookRules`. ### Bucket behavior -- **TTSR bucket**: any enabled rule with a non-empty parsed `condition` that `TtsrManager.addRule(...)` accepts. Takes priority over other buckets. +- **TTSR bucket**: any enabled rule with a non-empty parsed `condition` (regex) or `astCondition` (ast-grep patterns) that `TtsrManager.addRule(...)` accepts. Takes priority over other buckets. - **Always-apply bucket**: `alwaysApply === true`, not TTSR. Full content injected into system prompt. Resolvable via `rule://`. - **Rulebook bucket**: must have description, must not be TTSR, must not be `alwaysApply`. Listed in system prompt by name+description; content read on demand via `rule://`. -- A rule with both `condition` and `alwaysApply` goes to TTSR only if TTSR registration accepts it; otherwise it can fall through to always-apply. +- A rule with both a trigger condition and `alwaysApply` goes to TTSR only if TTSR registration accepts it; otherwise it can fall through to always-apply. - A rule with both `alwaysApply` and `description` goes to always-apply only (not rulebook). ## 6. How metadata affects runtime surfaces @@ -219,10 +220,11 @@ After rule discovery in `createAgentSession` (`sdk.ts`), `bucketRules(...)` appl - **Full rule content is auto-injected into the system prompt** (before the rulebook rules section). - Rule is also addressable via `rule://` for re-reading. -### `condition`, `scope`, and `interruptMode` +### `condition`, `astCondition`, `scope`, and `interruptMode` -- `condition` is the current TTSR trigger field; legacy `ttsr_trigger` / `ttsrTrigger` are accepted as fallback inputs during parsing. -- `scope` narrows TTSR matching scope. A condition token that looks like a file glob becomes `tool:edit()` and `tool:write()` scope entries plus catch-all condition `.*`. +- `condition` is the regex TTSR trigger field; legacy `ttsr_trigger` / `ttsrTrigger` are accepted as fallback inputs during parsing. +- `astCondition` is the ast-grep trigger field: a string or list of structural patterns, kept verbatim (no glob inference). It only matches on edit/write tool streams, where the language is inferred from the file path. A rule may set `condition`, `astCondition`, or both. +- `scope` narrows TTSR matching scope. A `condition` token that looks like a file glob becomes `tool:edit()` and `tool:write()` scope entries plus catch-all condition `.*`; `astCondition` tokens never trigger this shorthand. - `interruptMode` can override the global TTSR interrupt mode for the rule. ## 7. System prompt inclusion path @@ -240,18 +242,18 @@ This is advisory/contextual: prompt text asks the model to read applicable rules ## 8. `rule://` internal URL behavior -`RuleProtocolHandler` is registered with: +`RuleProtocolHandler` resolves against the process-global active-rule snapshot +installed once per top-level session in `sdk.ts`: ```ts -new RuleProtocolHandler({ - getRules: () => [...rulebookRules, ...alwaysApplyRules], -}); +setActiveRules([...rulebookRules, ...alwaysApplyRules, ...ttsrManager.getRules()]); ``` Implications: -- `rule://` resolves against both **rulebookRules** and **alwaysApplyRules**. -- TTSR-only rules and rules with no description and no `alwaysApply` are not addressable via `rule://`. +- `rule://` resolves against **rulebookRules**, **alwaysApplyRules**, and **registered TTSR rules**. +- TTSR rules are bucketed out before rulebook/always, but `ttsrManager.getRules()` re-adds them to the snapshot so a triggered rule (e.g. a builtin) stays addressable for re-reading. +- Rules with no description, no `alwaysApply`, and no accepted TTSR condition are not addressable via `rule://`. - Resolution is exact name match. - Unknown names return error listing available rule names. - Returned content is raw `rule.content` (frontmatter stripped), content type `text/markdown`. @@ -260,5 +262,5 @@ Implications: 1. The rule providers currently loaded for `rules` are `native`, `agents`, `cursor`, `windsurf`, `cline`, and embedded `builtin-defaults`; provider files for other tools may parse other config formats but do not register rule loaders. 2. `globs` metadata is surfaced to prompt/UI and is used as a global path gate for TTSR matching, but it is not used to automatically select rulebook rules for `rule://`. -3. Rule selection for `rule://` includes rulebook and always-apply rules, but not TTSR-only rules. +3. Rule selection for `rule://` includes rulebook, always-apply, and registered TTSR rules (so a triggered TTSR rule can be re-read), but not rules that registered no condition and carry neither a description nor `alwaysApply`. 4. Discovery warnings (`loadCapability("rules").warnings`) are produced but `createAgentSession` does not currently surface/log them in this path. diff --git a/docs/skills/authoring-hooks.md b/docs/skills/authoring-hooks.md index 37218e30f..65f9678ca 100644 --- a/docs/skills/authoring-hooks.md +++ b/docs/skills/authoring-hooks.md @@ -179,11 +179,15 @@ export default function rmRfBlocker(omp: HookAPI): void { ```ts import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/hooks"; -// Matches common API key patterns: sk-..., pk-..., AKIA..., ghp_..., etc. +// Common API-key shapes. Not exhaustive — providers using bespoke formats +// (Anthropic `sk-ant-…`, JWT-style bearers, gateway-specific prefixes, etc.) +// need their own entries. const SECRET_PATTERNS = [ /\b(sk|pk)-[a-zA-Z0-9]{20,}\b/g, /\bAKIA[A-Z0-9]{16}\b/g, /\bghp_[a-zA-Z0-9]{36}\b/g, + // Zhipu / GLM Coding Plan: `.` (no `sk-` prefix). + /\b[a-zA-Z0-9]{16,}\.[a-zA-Z0-9]{16,}\b/g, /\b[a-zA-Z0-9_-]{20,}\s*=\s*["']?[a-zA-Z0-9._/+=-]{20,}["']?/g, ]; diff --git a/docs/ttsr-injection-lifecycle.md b/docs/ttsr-injection-lifecycle.md index 375c2c374..6f6a63682 100644 --- a/docs/ttsr-injection-lifecycle.md +++ b/docs/ttsr-injection-lifecycle.md @@ -43,12 +43,18 @@ const { rulebookRules, alwaysApplyRules } = bucketRules( Registration is skipped when: -- `rule.condition` is absent or all condition regexes fail to compile +- both `rule.condition` (regex) and `rule.astCondition` (ast-grep patterns) are absent, or every regex condition fails to compile and there are no AST conditions - a rule with the same `rule.name` was already registered in this manager - the rule scope excludes all monitored streams Invalid regex conditions and unreachable scopes are logged as warnings and ignored; session startup continues. If a TTSR rule defines `globs`, those globs are compiled as a global file-path gate for matching. +### AST conditions (`astCondition`) + +A rule may carry `astCondition`: a list of [ast-grep](https://ast-grep.github.io/) patterns (OR'd, same as regex `condition`), matched structurally instead of textually. A repeated metavariable inside one pattern requires both occurrences to be equal (`if ($X) clearTimeout($X)` matches but `if ($X) clearTimeout($Y)` does not). + +AST conditions only evaluate on **edit/write tool-argument streams** — they need a language, which is inferred from the file extension on the tool's path argument, and they match against the tool's reconstructed source snapshot (`matcherDigest`), not the raw wire delta. Matching is performed in memory by the native `astMatch` engine (no temp files) with Smart strictness. Streams without a usable file path (prose, thinking, path-less tool calls) skip AST conditions entirely. A rule may mix `condition` and `astCondition`; the regex paths keep working on every scope while AST paths apply only to those tool streams. + ### Setting caveat `TtsrSettings.enabled` is loaded into the manager but is not currently checked in runtime gating. If TTSR rules exist, matching still runs. @@ -69,9 +75,10 @@ When assistant updates arrive and rules exist: - monitor `text_delta`, `thinking_delta`, and `toolcall_delta` - append delta into a source/tool scoped manager buffer -- call `checkDelta(delta, matchContext)` +- call `checkDelta(delta, matchContext)` (synchronous regex matching) +- for edit/write tool streams, when `hasAstRules()` is true, `await checkAstSnapshot(snapshot, matchContext)` (asynchronous AST matching) -`checkDelta()` iterates registered rules and returns all matching rules that pass scope, global path-glob, condition, and repeat policy checks. +`checkDelta()` iterates registered rules and returns all matching rules that pass scope, global path-glob, regex condition, and repeat policy checks. `checkAstSnapshot()` applies the same scope/path/repeat gates, then runs each candidate rule's `astCondition` patterns against the snapshot via the native `astMatch` engine. It is throttled per stream key: an identical consecutive snapshot (common when only non-source arguments change between deltas) is skipped without re-running the matcher. Both paths feed their matches through the same trigger-decision handler. ## 3. Trigger decision and immediate abort path diff --git a/package.json b/package.json index 05300bcde..dfcf52836 100644 --- a/package.json +++ b/package.json @@ -20,15 +20,15 @@ "@huggingface/transformers": "^4.2.0", "@mozilla/readability": "^0.6.0", "@napi-rs/cli": "3.7.0", - "@oh-my-pi/hashline": "15.10.4", - "@oh-my-pi/omp-stats": "15.10.4", - "@oh-my-pi/pi-agent-core": "15.10.4", - "@oh-my-pi/pi-ai": "15.10.4", - "@oh-my-pi/pi-coding-agent": "15.10.4", - "@oh-my-pi/pi-mnemopi": "15.10.4", - "@oh-my-pi/pi-natives": "15.10.4", - "@oh-my-pi/pi-tui": "15.10.4", - "@oh-my-pi/pi-utils": "15.10.4", + "@oh-my-pi/hashline": "15.10.5", + "@oh-my-pi/omp-stats": "15.10.5", + "@oh-my-pi/pi-agent-core": "15.10.5", + "@oh-my-pi/pi-ai": "15.10.5", + "@oh-my-pi/pi-coding-agent": "15.10.5", + "@oh-my-pi/pi-mnemopi": "15.10.5", + "@oh-my-pi/pi-natives": "15.10.5", + "@oh-my-pi/pi-tui": "15.10.5", + "@oh-my-pi/pi-utils": "15.10.5", "@opentelemetry/api": "^1.9.1", "@opentelemetry/context-async-hooks": "^2.7.1", "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 2d896b6ee..e76d567be 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,24 @@ ## [Unreleased] +## [15.10.5] - 2026-06-08 +### Removed + +- Removed the `maxToolCallsPerTurn` option from `AgentOptions` and `AgentLoopConfig`, so assistant turns are no longer capped after a configured number of completed tool calls + +### Fixed + +- Fixed stalled aborted assistant responses so the run now stops without waiting for provider iterator cleanup and returns the aborted message promptly +- Fixed `afterToolCall` handling so it now runs for completed tool executions even after a run is aborted so tool post-processing still applies +- Fixed `agentLoopDetailed().detailed()` so run telemetry and coverage are captured before `stream.result()` resolves. +- Fixed agent-loop stream invariants so `agentLoopContinue` no longer mutates the caller's message array, emitted assistant events snapshot mutable provider content, terminal provider events win over late abort signals, transformed tool arguments are reflected consistently in hooks/events, and successful run-end telemetry fires from the same finalization path as failures. +- Fixed tool result parsing to mark assistant tool outputs with unsupported content block shapes as errors and include a diagnostic text block +- Fixed GPT-5 Harmony leakage handling by recovering valid leaked tool calls when possible and discarding leaked partial assistant output before retrying +- Fixed tool-call cancellation handling so aborted tools are marked aborted with an explicit reason and do not report generic errors +- Fixed tool-call completion so assistant messages on abort keep only completed tool-call blocks and continue processing tool calls when a length stop still included results +- Fixed deliberate aborts (TTSR rule matches, user-interrupt labels) so a mid-stream tool-call block that never reached `toolcall_end` is retained on the aborted assistant message and paired with a placeholder result labeled by the abort reason, instead of being dropped; anonymous aborts (bare `abort()`) still drop incomplete tool calls whose partial arguments are unsafe to replay +- Fixed runs that stopped with reason `length` after returning tool results so execution continues to handle additional tool calls + ## [15.10.3] - 2026-06-08 ### Added diff --git a/packages/agent/package.json b/packages/agent/package.json index 12bae08cb..3eba980f4 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": "15.10.4", + "version": "15.10.5", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 4e697ed4a..876a48b48 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -23,6 +23,7 @@ import { type HarmonyDetection, type HarmonyRecoveredToolCall, isHarmonyLeakMitigationTarget, + recoverHarmonyToolCall, signalListLabel, } from "./harmony-leak"; import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector"; @@ -68,6 +69,76 @@ class HarmonyLeakInterruption extends Error { } } +type AssistantContentBlock = AssistantMessage["content"][number]; +type AssistantToolCallBlock = Extract; +type CloneableRecord = Record; + +function cloneUnknown(value: unknown): unknown { + if (Array.isArray(value)) return value.map(cloneUnknown); + if (!value || typeof value !== "object") return value; + const source = value as CloneableRecord; + const out: CloneableRecord = {}; + for (const [key, child] of Object.entries(source)) { + out[key] = cloneUnknown(child); + } + return out; +} + +function cloneToolArguments(args: AssistantToolCallBlock["arguments"]): AssistantToolCallBlock["arguments"] { + return cloneUnknown(args) as AssistantToolCallBlock["arguments"]; +} + +function snapshotAssistantContentBlock(block: AssistantContentBlock): AssistantContentBlock { + switch (block.type) { + case "text": + return { ...block }; + case "thinking": + return { ...block }; + case "redactedThinking": + return { ...block }; + case "toolCall": + return { ...block, arguments: cloneToolArguments(block.arguments) }; + } +} + +function snapshotAssistantMessage(message: AssistantMessage): AssistantMessage { + return { + ...message, + content: message.content.map(snapshotAssistantContentBlock), + usage: { + ...message.usage, + cost: { ...message.usage.cost }, + }, + disabledFeatures: message.disabledFeatures ? [...message.disabledFeatures] : undefined, + }; +} + +function snapshotAssistantMessageEvent(event: AssistantMessageEvent): AssistantMessageEvent { + switch (event.type) { + case "start": + return { ...event, partial: snapshotAssistantMessage(event.partial) }; + case "text_start": + case "text_delta": + case "text_end": + case "thinking_start": + case "thinking_delta": + case "thinking_end": + case "toolcall_start": + case "toolcall_delta": + return { ...event, partial: snapshotAssistantMessage(event.partial) }; + case "toolcall_end": + return { + ...event, + toolCall: snapshotAssistantContentBlock(event.toolCall) as AssistantToolCallBlock, + partial: snapshotAssistantMessage(event.partial), + }; + case "done": + return { ...event, message: snapshotAssistantMessage(event.message) }; + case "error": + return { ...event, error: snapshotAssistantMessage(event.error) }; + } +} + /** * Normalize a value coming back from `tool.execute()` (or its streaming partial-update callback) * into a structurally valid {@link AgentToolResult}. @@ -77,7 +148,7 @@ class HarmonyLeakInterruption extends Error { * (missing `content` array → crash on reload). We coerce at the single boundary where untyped * results enter the agent loop, so every downstream consumer can rely on the type. */ -function coerceToolResult(raw: unknown): { result: AgentToolResult; malformed: boolean } { +function coerceToolResult(raw: unknown): { result: AgentToolResult; malformed: boolean } { const rawObj = raw && typeof raw === "object" ? (raw as Record) : null; const rawContent = rawObj?.content; const details = rawObj && "details" in rawObj ? rawObj.details : {}; @@ -98,8 +169,12 @@ function coerceToolResult(raw: unknown): { result: AgentToolResult; malform } const content: AgentToolResult["content"] = []; + let invalidBlocks = 0; for (const block of rawContent) { - if (!block || typeof block !== "object" || !("type" in block)) continue; + if (!block || typeof block !== "object" || !("type" in block)) { + invalidBlocks++; + continue; + } if (block.type === "text" && typeof (block as { text?: unknown }).text === "string") { content.push({ type: "text", text: sanitizeText((block as { text: string }).text) }); } else if ( @@ -108,9 +183,20 @@ function coerceToolResult(raw: unknown): { result: AgentToolResult; malform typeof (block as { mimeType?: unknown }).mimeType === "string" ) { content.push(block as { type: "image"; data: string; mimeType: string }); + } else { + invalidBlocks++; } } - return { result: { content, details, ...(explicitError ? { isError: true } : {}) }, malformed: false }; + if (invalidBlocks > 0) { + content.push({ + type: "text", + text: `Tool returned an invalid result: ${invalidBlocks} content block${invalidBlocks === 1 ? "" : "s"} had an unsupported shape.`, + }); + } + return { + result: { content, details, ...(explicitError || invalidBlocks > 0 ? { isError: true } : {}) }, + malformed: invalidBlocks > 0, + }; } /** @@ -176,7 +262,7 @@ export function agentLoopContinue( (async () => { const newMessages: AgentMessage[] = []; - const currentContext: AgentContext = { ...context }; + const currentContext: AgentContext = { ...context, messages: [...context.messages] }; stream.push({ type: "agent_start" }); stream.push({ type: "turn_start" }); @@ -313,22 +399,26 @@ function normalizeMessagesForProvider( return messages; } - let changed = false; - const normalized = messages.map(message => { + let hasThinking = false; + for (const message of messages) { + if (message.role !== "assistant" || !Array.isArray(message.content)) continue; + for (const block of message.content) { + if (block.type === "thinking") { + hasThinking = true; + break; + } + } + if (hasThinking) break; + } + if (!hasThinking) return messages; + + return messages.map(message => { if (message.role !== "assistant" || !Array.isArray(message.content)) { return message; } - const filtered = message.content.filter(block => block.type !== "thinking"); - if (filtered.length === message.content.length) { - return message; - } - - changed = true; - return { ...message, content: filtered }; + return filtered.length === message.content.length ? message : { ...message, content: filtered }; }); - - return changed ? normalized : messages; } export const INTENT_FIELD = "_i"; @@ -445,27 +535,6 @@ interface StepCounter { count: number; } -function normalizeMaxToolCallsPerTurn(value: number | undefined): number | undefined { - if (value === undefined || !Number.isFinite(value)) return undefined; - const normalized = Math.trunc(value); - return normalized > 0 ? normalized : undefined; -} - -function cloneAssistantMessageForToolCallCap(message: AssistantMessage): AssistantMessage { - return { - ...message, - content: message.content.map(block => { - if (block.type === "toolCall") { - return { ...block, arguments: structuredClone(block.arguments) }; - } - return { ...block }; - }), - stopReason: "toolUse", - errorMessage: undefined, - errorStatus: undefined, - }; -} - /** * Resolve aside entries at the moment the loop is about to inject them. Each entry * is either a ready {@link AgentMessage} or a sync thunk evaluated here so the @@ -573,6 +642,12 @@ async function runLoopBody( continue; } } + if (recovered) { + message = snapshotAssistantMessage(message); + currentContext.messages.push(message); + stream.push({ type: "message_start", message: snapshotAssistantMessage(message) }); + stream.push({ type: "message_end", message: snapshotAssistantMessage(message) }); + } newMessages.push(message); let steeringMessagesFromExecution: AgentMessage[] | undefined; @@ -661,13 +736,24 @@ async function runLoopBody( status: "skipped", }); } + if (message.stopReason === "length" && toolResults.length > 0) { + hasMoreToolCalls = true; + } } stream.push({ type: "turn_end", message, toolResults }); const steering = steeringMessagesFromExecution ?? ((await config.getSteeringMessages?.()) || []); - const asides = resolveAsides(await config.getAsideMessages?.()); - pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering; + if (hasMoreToolCalls) { + // Mid-work: fold any non-interrupting asides into the next turn alongside steering. + const asides = resolveAsides(await config.getAsideMessages?.()); + pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering; + } else { + // Stop boundary: only steering (live user input) forces another turn here. Leave + // asides for the outer drain below so a passive aside can't trigger an extra model + // turn ahead of a queued follow-up — the outer drain batches asides + follow-ups together. + pendingMessages = steering; + } } // Agent would stop here. Drain non-interrupting asides + follow-up messages. @@ -761,18 +847,11 @@ async function streamAssistantResponse( const dynamicReasoning = config.getReasoning?.(); const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(config.model); const harmonyAbortController = harmonyMitigationEnabled ? new AbortController() : undefined; - const maxToolCallsPerTurn = normalizeMaxToolCallsPerTurn(config.maxToolCallsPerTurn); - const toolCallCapAbortController = maxToolCallsPerTurn === undefined ? undefined : new AbortController(); - const requestSignals: AbortSignal[] = []; - if (signal) requestSignals.push(signal); - if (harmonyAbortController) requestSignals.push(harmonyAbortController.signal); - if (toolCallCapAbortController) requestSignals.push(toolCallCapAbortController.signal); - const requestSignal = - requestSignals.length === 0 - ? undefined - : requestSignals.length === 1 - ? requestSignals[0] - : AbortSignal.any(requestSignals); + const requestSignal = harmonyAbortController + ? signal + ? AbortSignal.any([signal, harmonyAbortController.signal]) + : harmonyAbortController.signal + : signal; const effectiveTemperature = harmonyRetryAttempt > 0 && config.temperature !== undefined ? config.temperature + 0.05 : config.temperature; const effectiveToolChoice = dynamicToolChoice ?? config.toolChoice; @@ -844,27 +923,27 @@ async function streamAssistantResponse( let partialMessage: AssistantMessage | null = null; let addedPartial = false; + const completedToolCallIds = new Set(); const responseIterator = response[Symbol.asyncIterator](); - let completedToolCalls = 0; - let cappedMessage: AssistantMessage | undefined; - let capFinalized = false; - - const finishCappedAssistantMessage = async (): Promise => { - if (!cappedMessage) return undefined; - responseIterator.return?.()?.catch(() => {}); - if (!capFinalized) { - if (addedPartial) { - context.messages[context.messages.length - 1] = cappedMessage; - } else { - context.messages.push(cappedMessage); - stream.push({ type: "message_start", message: { ...cappedMessage } }); - } - stream.push({ type: "message_end", message: cappedMessage }); - await finishChat(cappedMessage); - capFinalized = true; + const finishAbortedStream = async (): Promise => { + try { + const cleanup = responseIterator.return?.(); + if (cleanup) void cleanup.catch(() => {}); + } catch { + // Provider cancellation failures cannot change the committed aborted message. } - return cappedMessage; + const aborted = emitAbortedAssistantMessage( + partialMessage, + addedPartial, + completedToolCallIds, + context, + config, + stream, + requestSignal, + ); + await finishChat(aborted); + return aborted; }; // Set up a single abort race: register the abort listener once for the whole @@ -874,16 +953,7 @@ async function streamAssistantResponse( let detachAbortListener: (() => void) | undefined; if (requestSignal) { if (requestSignal.aborted) { - const aborted = emitAbortedAssistantMessage( - partialMessage, - addedPartial, - context, - config, - stream, - requestSignal, - ); - await finishChat(aborted); - return aborted; + return await finishAbortedStream(); } const { promise, resolve } = Promise.withResolvers(); const onAbort = () => resolve(ABORTED); @@ -898,45 +968,51 @@ async function streamAssistantResponse( if (abortRacePromise) { const result = await Promise.race([responseIterator.next(), abortRacePromise]); if (result === ABORTED) { - if (toolCallCapAbortController?.signal.aborted) { - const capped = await finishCappedAssistantMessage(); - if (capped) return capped; - } - responseIterator.return?.()?.catch(() => {}); - const aborted = emitAbortedAssistantMessage( - partialMessage, - addedPartial, - context, - config, - stream, - requestSignal, - ); - await finishChat(aborted); - return aborted; + return await finishAbortedStream(); } next = result; } else { next = await responseIterator.next(); } - if (requestSignal?.aborted) { - if (toolCallCapAbortController?.signal.aborted) { - const capped = await finishCappedAssistantMessage(); - if (capped) return capped; - } - const aborted = emitAbortedAssistantMessage( - partialMessage, - addedPartial, - context, - config, - stream, - requestSignal, - ); - await finishChat(aborted); - return aborted; - } if (next.done) break; const event = next.value; + if (event.type === "done" || event.type === "error") { + let finalMessage = retainCompletedToolCalls(await response.result(), completedToolCallIds); + if (harmonyMitigationEnabled) { + const detection = detectHarmonyLeakInAssistantMessage(finalMessage); + if (detection) { + const recovered = recoverHarmonyToolCall(finalMessage, detection); + const removed = recovered?.removed ?? extractHarmonyRemoved(finalMessage, detection); + if (addedPartial) { + emitDiscardedHarmonyPartial( + partialMessage, + stream, + `Discarded after GPT-5 Harmony protocol leakage (${signalListLabel(detection.signals)})`, + ); + context.messages.pop(); + addedPartial = false; + } + throw new HarmonyLeakInterruption(detection, removed, recovered); + } + } + finalMessage = snapshotAssistantMessage(finalMessage); + if (addedPartial) { + context.messages[context.messages.length - 1] = finalMessage; + } else { + context.messages.push(finalMessage); + } + if (!addedPartial) { + stream.push({ type: "message_start", message: snapshotAssistantMessage(finalMessage) }); + } + stream.push({ type: "message_end", message: snapshotAssistantMessage(finalMessage) }); + await finishChat(finalMessage); + return finalMessage; + } + if (requestSignal?.aborted) { + return await finishAbortedStream(); + } + // Yield to the event loop periodically to prevent busy-wait // when the LLM is streaming chunks faster than the loop can rest. await yieldIfDue(); @@ -946,7 +1022,7 @@ async function streamAssistantResponse( partialMessage = event.partial; context.messages.push(partialMessage); addedPartial = true; - stream.push({ type: "message_start", message: { ...partialMessage } }); + stream.push({ type: "message_start", message: snapshotAssistantMessage(partialMessage) }); break; case "text_start": @@ -959,72 +1035,48 @@ async function streamAssistantResponse( case "toolcall_delta": case "toolcall_end": if (partialMessage) { + if (event.type === "toolcall_end") { + completedToolCallIds.add(event.toolCall.id); + } partialMessage = event.partial; context.messages[context.messages.length - 1] = partialMessage; config.onAssistantMessageEvent?.(partialMessage, event); - if (signal?.aborted) { - continue; - } stream.push({ type: "message_update", - assistantMessageEvent: event, - message: { ...partialMessage }, + assistantMessageEvent: snapshotAssistantMessageEvent(event), + message: snapshotAssistantMessage(partialMessage), }); - if (event.type === "toolcall_end" && maxToolCallsPerTurn !== undefined) { - completedToolCalls++; - if (completedToolCalls >= maxToolCallsPerTurn) { - cappedMessage = cloneAssistantMessageForToolCallCap(partialMessage); - toolCallCapAbortController?.abort(); - const capped = await finishCappedAssistantMessage(); - if (capped) return capped; - } - } } break; - - case "done": - case "error": { - const finalMessage = await response.result(); - if (harmonyMitigationEnabled) { - const detection = detectHarmonyLeakInAssistantMessage(finalMessage); - if (detection) { - const removed = extractHarmonyRemoved(finalMessage, detection); - if (addedPartial) { - context.messages.pop(); - addedPartial = false; - } - throw new HarmonyLeakInterruption(detection, removed); - } - } - if (addedPartial) { - context.messages[context.messages.length - 1] = finalMessage; - } else { - context.messages.push(finalMessage); - } - if (!addedPartial) { - stream.push({ type: "message_start", message: { ...finalMessage } }); - } - stream.push({ type: "message_end", message: finalMessage }); - await finishChat(finalMessage); - return finalMessage; - } } } } finally { detachAbortListener?.(); } - const trailing = await response.result(); + let trailing = await response.result(); if (harmonyMitigationEnabled) { const detection = detectHarmonyLeakInAssistantMessage(trailing); if (detection) { + const recovered = recoverHarmonyToolCall(trailing, detection); + const removed = recovered?.removed ?? extractHarmonyRemoved(trailing, detection); if (addedPartial) { + emitDiscardedHarmonyPartial( + partialMessage, + stream, + `Discarded after GPT-5 Harmony protocol leakage (${signalListLabel(detection.signals)})`, + ); context.messages.pop(); addedPartial = false; } - throw new HarmonyLeakInterruption(detection, extractHarmonyRemoved(trailing, detection)); + throw new HarmonyLeakInterruption(detection, removed, recovered); } } + trailing = snapshotAssistantMessage(trailing); + if (addedPartial) { + context.messages[context.messages.length - 1] = trailing; + stream.push({ type: "message_end", message: snapshotAssistantMessage(trailing) }); + } await finishChat(trailing); return trailing; }); @@ -1038,6 +1090,33 @@ async function streamAssistantResponse( } } +function retainCompletedToolCalls( + message: AssistantMessage, + completedToolCallIds: ReadonlySet, +): AssistantMessage { + if (message.stopReason !== "error" && message.stopReason !== "aborted") return message; + let changed = false; + const content = message.content.filter(block => { + if (block.type !== "toolCall") return true; + const keep = completedToolCallIds.has(block.id); + if (!keep) changed = true; + return keep; + }); + return changed ? { ...message, content } : message; +} + +function emitDiscardedHarmonyPartial( + partialMessage: AssistantMessage | null, + stream: EventStream, + errorMessage: string, +): void { + if (!partialMessage) return; + stream.push({ + type: "message_end", + message: snapshotAssistantMessage({ ...partialMessage, stopReason: "error", errorMessage }), + }); +} + /** Resolve the human-readable reason an abort carried. A caller that aborts via * `AbortController.abort(reason)` with a string or a non-`AbortError` `Error` * (e.g. the coding agent's user-interrupt label) gets that text surfaced on the @@ -1053,16 +1132,31 @@ export function abortReasonText(signal: AbortSignal | undefined): string { return "Request was aborted"; } +/** True when an abort carried a *deliberate*, human-meaningful reason — a string + * reason or a non-`AbortError` `Error` (TTSR rule match, user-interrupt label). + * A bare `abort()` (default `AbortError` `DOMException`) is anonymous and returns + * false. Used to decide whether a mid-stream tool call survives the abort: a + * deliberate interruption is a conscious decision made after the (partial) call + * was observed, so the block is retained and paired with a labeled placeholder; + * an anonymous abort drops incomplete calls whose args may be unsafe to replay. */ +function isExplicitAbortReason(signal: AbortSignal | undefined): boolean { + const reason = signal?.reason; + if (typeof reason === "string") return reason.trim().length > 0; + if (reason instanceof Error) return reason.name !== "AbortError" && reason.message.trim().length > 0; + return false; +} + function emitAbortedAssistantMessage( partialMessage: AssistantMessage | null, addedPartial: boolean, + completedToolCallIds: ReadonlySet, context: AgentContext, config: AgentLoopConfig, stream: EventStream, requestSignal: AbortSignal | undefined, ): AssistantMessage { const errorMessage = abortReasonText(requestSignal); - const abortedMessage: AssistantMessage = partialMessage + const base: AssistantMessage = partialMessage ? { ...partialMessage, stopReason: "aborted", errorMessage } : { role: "assistant", @@ -1082,13 +1176,19 @@ function emitAbortedAssistantMessage( errorMessage, timestamp: Date.now(), }; + // A deliberate, labeled abort (TTSR rule match, user interrupt) keeps every + // committed tool-call block so the loop pairs it with a placeholder labeled by + // `errorMessage`; an anonymous abort still drops calls that never completed + // (no `toolcall_end`), whose partial args are unsafe to replay. + const retained = isExplicitAbortReason(requestSignal) ? base : retainCompletedToolCalls(base, completedToolCallIds); + const abortedMessage = snapshotAssistantMessage(retained); if (addedPartial) { context.messages[context.messages.length - 1] = abortedMessage; } else { context.messages.push(abortedMessage); - stream.push({ type: "message_start", message: { ...abortedMessage } }); + stream.push({ type: "message_start", message: snapshotAssistantMessage(abortedMessage) }); } - stream.push({ type: "message_end", message: abortedMessage }); + stream.push({ type: "message_end", message: snapshotAssistantMessage(abortedMessage) }); return abortedMessage; } @@ -1126,7 +1226,7 @@ async function executeToolCalls( : steeringAbortController.signal; const interruptState = { triggered: false }; let steeringMessages: AgentMessage[] | undefined; - let steeringCheck: Promise | null = null; + let steeringCheckTail: Promise = Promise.resolve(); const records = toolCalls.map(toolCall => ({ toolCall, @@ -1150,21 +1250,17 @@ async function executeToolCalls( if (!shouldInterruptImmediately || !getSteeringMessages || interruptState.triggered) { return; } - if (steeringCheck) { - await steeringCheck; - return; - } - steeringCheck = (async () => { + const check = steeringCheckTail.then(async () => { + if (interruptState.triggered) return; const steering = await getSteeringMessages(); if (steering.length > 0) { steeringMessages = steering; interruptState.triggered = true; steeringAbortController.abort(); } - })().finally(() => { - steeringCheck = null; }); - await steeringCheck; + steeringCheckTail = check.catch(() => {}); + await check; }; const emitToolResult = (record: (typeof records)[number], result: AgentToolResult, isError: boolean): void => { @@ -1236,6 +1332,16 @@ async function executeToolCalls( } } record.args = argsForExecution; + if (toolSignal.aborted) { + record.skipped = true; + recordSkippedTool(telemetry, { + toolCallId: toolCall.id, + toolName: toolCall.name, + status: "aborted", + }); + emitToolResult(record, createToolSignalAbortedResult(toolSignal), true); + return; + } record.started = true; stream.push({ type: "tool_execution_start", @@ -1259,10 +1365,16 @@ async function executeToolCalls( let result: AgentToolResult = { content: [], details: {} }; let isError = false; let caughtError: unknown; + let completedToolExecution = false; await runInActiveSpan(toolSpan, async () => { try { if (!tool) throw new Error(`Tool ${toolCall.name} not found`); + if (toolSignal.aborted) { + result = createToolSignalAbortedResult(toolSignal); + isError = true; + return; + } let effectiveArgs: Record; try { @@ -1289,8 +1401,15 @@ async function executeToolCalls( throw new ToolCallBlockedError(beforeResult.reason); } } - // Reflect post-hook args so emitted tool results / afterToolCall see what actually executed. - record.args = effectiveArgs; + if (toolSignal.aborted) { + result = createToolSignalAbortedResult(toolSignal); + isError = true; + return; + } + const executionArgs = transformToolCallArguments + ? transformToolCallArguments(effectiveArgs, toolCall.name) + : effectiveArgs; + record.args = executionArgs; const toolContext = getToolContext ? getToolContext({ @@ -1302,19 +1421,20 @@ async function executeToolCalls( : undefined; const rawResult = await tool.execute( toolCall.id, - transformToolCallArguments ? transformToolCallArguments(effectiveArgs, toolCall.name) : effectiveArgs, + executionArgs, toolSignal, partialResult => { stream.push({ type: "tool_execution_update", toolCallId: toolCall.id, toolName: toolCall.name, - args: effectiveArgs, + args: executionArgs, partialResult: coerceToolResult(partialResult).result, }); }, toolContext, ); + completedToolExecution = true; const coerced = coerceToolResult(rawResult); result = coerced.result; if (coerced.malformed || result.isError) isError = true; @@ -1327,7 +1447,7 @@ async function executeToolCalls( isError = true; } - if (afterToolCall) { + if (afterToolCall && (!toolSignal.aborted || completedToolExecution)) { try { const after = await afterToolCall( { @@ -1341,12 +1461,17 @@ async function executeToolCalls( toolSignal, ); if (after) { - result = { + // Re-normalize the post-hook result: `afterToolCall` is untyped user/extension + // code and may return malformed `content` (non-array / invalid blocks), which + // would otherwise be persisted verbatim and corrupt the session — the same + // hazard `coerceToolResult` guards on the execute path. + const coerced = coerceToolResult({ content: after.content ?? result.content, details: after.details ?? result.details, isError: after.isError ?? result.isError, - }; - isError = after.isError ?? isError; + }); + result = coerced.result; + isError = coerced.malformed || (after.isError ?? isError); } } catch (e) { caughtError = e; @@ -1360,23 +1485,30 @@ async function executeToolCalls( }); const interrupted = interruptState.triggered; - if (interrupted) { + const abortedDuringExecution = toolSignal.aborted && isError; + if (interrupted && isError) { + // Steering/abort fired AND this tool failed — it was cut off before producing a + // usable result, so report it as skipped. record.skipped = true; emitToolResult(record, createSkippedToolResult(), true); } else { + // No interrupt, or the tool finished (successfully or with a genuine error) before + // the interrupt landed. Keep its real result: a completed tool already ran its side + // effects, so the model must see what actually happened rather than a false "skipped". emitToolResult(record, result, isError); } const firstTextBlock = result.content?.[0]; const errorMessageForSpan = caughtError === undefined && isError && firstTextBlock?.type === "text" ? firstTextBlock.text : undefined; - const status = interrupted - ? "aborted" - : caughtError instanceof ToolCallBlockedError - ? "blocked" - : isError - ? "error" - : "ok"; + const status = + (interrupted && isError) || abortedDuringExecution + ? "aborted" + : caughtError instanceof ToolCallBlockedError + ? "blocked" + : isError + ? "error" + : "ok"; finishExecuteToolSpan(telemetry, toolSpan, { result, isError, @@ -1482,6 +1614,14 @@ function createAbortedToolResult( return toolResultMessage; } +function createToolSignalAbortedResult(signal: AbortSignal): AgentToolResult { + const reason = abortReasonText(signal); + return { + content: [{ type: "text", text: `Tool was not executed because the run was aborted: ${reason}.` }], + details: {}, + }; +} + function createSkippedToolResult(): AgentToolResult { return { content: [{ type: "text", text: "Skipped due to queued user message." }], diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 3e6a5dfb1..4a339a1c3 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -110,12 +110,6 @@ export interface AgentOptions { */ interruptMode?: "immediate" | "wait"; - /** - * Maximum completed tool calls to accept from one streamed assistant turn before - * executing the batch. Undefined disables batching. - */ - maxToolCallsPerTurn?: number; - /** * API format for Kimi Code provider: "openai" or "anthropic" (default: "anthropic") */ @@ -288,7 +282,6 @@ export class Agent { #steeringMode: "all" | "one-at-a-time"; #followUpMode: "all" | "one-at-a-time"; #interruptMode: "immediate" | "wait"; - #maxToolCallsPerTurn?: number; #sessionId?: string; #promptCacheKey?: string; #metadata?: Record; @@ -350,7 +343,6 @@ export class Agent { this.#steeringMode = opts.steeringMode || "one-at-a-time"; this.#followUpMode = opts.followUpMode || "one-at-a-time"; this.#interruptMode = opts.interruptMode || "immediate"; - this.#maxToolCallsPerTurn = opts.maxToolCallsPerTurn; this.streamFn = opts.streamFn || streamSimple; this.#sessionId = opts.sessionId; this.#promptCacheKey = opts.promptCacheKey; @@ -588,14 +580,6 @@ export class Agent { this.#maxRetryDelayMs = value; } - get maxToolCallsPerTurn(): number | undefined { - return this.#maxToolCallsPerTurn; - } - - set maxToolCallsPerTurn(value: number | undefined) { - this.#maxToolCallsPerTurn = value; - } - get state(): AgentState { return this.#state; } @@ -967,7 +951,6 @@ export class Agent { serviceTier: this.#serviceTier, hideThinkingSummary: this.#hideThinkingSummary, interruptMode: this.#interruptMode, - maxToolCallsPerTurn: this.#maxToolCallsPerTurn, sessionId: this.#sessionId, promptCacheKey: this.#promptCacheKey, metadata: this.#metadataResolver ? undefined : this.#metadata, diff --git a/packages/agent/src/telemetry.ts b/packages/agent/src/telemetry.ts index 8655ccb6c..510eed286 100644 --- a/packages/agent/src/telemetry.ts +++ b/packages/agent/src/telemetry.ts @@ -1869,7 +1869,8 @@ export function finishInvokeAgentSpan( /** * Invoke {@link AgentTelemetryConfig.onRunEnd} on `telemetry` if set. Throws - are caught and logged via `console.warn` — telemetry callbacks NEVER turn a + * are caught and surfaced via the `onTelemetryWarning` hook (falling back to `console.warn` + * when no hook is set) — telemetry callbacks NEVER turn a * successful agent run into a failed one. Idempotent at the call site via * {@link AgentRunCollector.markRunEnded}; callers must check that before * calling this helper. diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 52db396e0..5777a9b82 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -47,14 +47,6 @@ export interface AgentLoopConfig extends SimpleStreamOptions { */ interruptMode?: "immediate" | "wait"; - /** - * Maximum completed tool calls to accept from one streamed assistant turn before - * cutting the provider stream and executing that batch. The cap is enforced on - * `toolcall_end` so every executed call has complete arguments. Undefined disables - * batching. - */ - maxToolCallsPerTurn?: number; - /** * Optional session identifier forwarded to LLM providers. * Used by providers that support session-based caching (e.g., OpenAI Codex). diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index 908a568ef..d91641162 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { agentLoop, agentLoopContinue, INTENT_FIELD } from "@oh-my-pi/pi-agent-core/agent-loop"; +import { agentLoop, agentLoopContinue, agentLoopDetailed, INTENT_FIELD } from "@oh-my-pi/pi-agent-core/agent-loop"; import type { AgentContext, AgentEvent, @@ -7,10 +7,9 @@ import type { AgentMessage, AgentTool, AgentToolContext, - StreamFn, ToolCallContext, } from "@oh-my-pi/pi-agent-core/types"; -import type { AssistantMessage, Message, ToolResultMessage } from "@oh-my-pi/pi-ai"; +import type { AssistantMessage, AssistantMessageEvent, Message, ToolResultMessage } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; import * as z from "zod/v4"; @@ -56,6 +55,24 @@ describe("agentLoop with AgentMessage", () => { expect(eventTypes).toContain("agent_end"); }); + it("returns detailed telemetry when awaiting detailed() directly", async () => { + const context: AgentContext = { + systemPrompt: ["You are helpful."], + messages: [], + tools: [], + }; + const mock = createMockModel({ responses: [{ content: ["Hi there!"] }] }); + const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter }; + + const { detailed } = agentLoopDetailed([createUserMessage("Hello")], context, config, undefined, mock.stream); + const result = await detailed(); + + expect(result.messages).toHaveLength(2); + expect(result.telemetry?.stepCount).toBe(1); + expect(result.telemetry?.chats.total).toBe(1); + expect(result.coverage?.modelsUsed).toEqual([mock.model.id]); + }); + it("retries when harmony leakage reaches the committed assistant message (openai-codex)", async () => { const context: AgentContext = { systemPrompt: ["You are helpful."], @@ -133,7 +150,7 @@ describe("agentLoop with AgentMessage", () => { tools: [], }; const mock = createMockModel(); - const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter, maxToolCallsPerTurn: 8 }; + const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter }; const controller = new AbortController(); // The mock provider would reject without a configured response; we want the // agent's abort path to kick in before any event is emitted. Use a raw stream @@ -157,6 +174,40 @@ describe("agentLoop with AgentMessage", () => { expect(events.map(event => event.type)).toContain("agent_end"); }); + it("does not wait for provider iterator cleanup when aborting a stalled response", async () => { + const context: AgentContext = { + systemPrompt: ["You are helpful."], + messages: [], + tools: [], + }; + const mock = createMockModel(); + const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter }; + const controller = new AbortController(); + let returnCalled = false; + const streamFn = () => + ({ + result: () => Promise.withResolvers().promise, + [Symbol.asyncIterator]: () => ({ + next: () => Promise.withResolvers>().promise, + return: () => { + returnCalled = true; + return Promise.withResolvers>().promise; + }, + }), + }) as AssistantMessageEventStream; + + const stream = agentLoop([createUserMessage("Hello")], context, config, controller.signal, streamFn); + queueMicrotask(() => controller.abort("stop now")); + const messages = await stream.result(); + + expect(returnCalled).toBe(true); + const finalMessage = messages[messages.length - 1]; + expect(finalMessage.role).toBe("assistant"); + if (finalMessage.role !== "assistant") throw new Error("Expected assistant message"); + expect(finalMessage.stopReason).toBe("aborted"); + expect(finalMessage.errorMessage).toBe("stop now"); + }); + it("surfaces a custom abort reason on the synthesized aborted message", async () => { const context: AgentContext = { systemPrompt: ["You are helpful."], @@ -164,7 +215,7 @@ describe("agentLoop with AgentMessage", () => { tools: [], }; const mock = createMockModel(); - const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter, maxToolCallsPerTurn: 8 }; + const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter }; const controller = new AbortController(); const streamFn = () => new AssistantMessageEventStream(); @@ -373,118 +424,6 @@ describe("agentLoop with AgentMessage", () => { } }); - it("cuts a streamed assistant turn after the configured completed tool-call batch", async () => { - const toolSchema = z.object({ value: z.string() }); - const executed: string[] = []; - const tool: AgentTool = { - name: "echo", - label: "Echo", - description: "Echo tool", - parameters: toolSchema, - async execute(_toolCallId, params) { - executed.push(params.value); - return { - content: [{ type: "text", text: `echoed: ${params.value}` }], - details: { value: params.value }, - }; - }, - }; - - const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] }; - const mock = createMockModel(); - let modelCalls = 0; - let firstRequestSignal: AbortSignal | undefined; - - const makeToolCall = (index: number): AssistantMessage["content"][number] => ({ - type: "toolCall", - id: `tool-${index}`, - name: "echo", - arguments: { value: String(index) }, - }); - const makeMessage = (count: number, stopReason: AssistantMessage["stopReason"] = "stop") => - createAssistantMessage( - Array.from({ length: count }, (_, index) => makeToolCall(index + 1)), - stopReason, - ); - - const streamFn: StreamFn = (_model, _llmContext, options) => { - modelCalls++; - const stream = new AssistantMessageEventStream(); - if (modelCalls > 1) { - queueMicrotask(() => { - const done = createAssistantMessage([{ type: "text", text: "done" }], "stop"); - stream.push({ type: "start", partial: done }); - stream.push({ type: "text_start", contentIndex: 0, partial: done }); - stream.push({ type: "text_delta", contentIndex: 0, delta: "done", partial: done }); - stream.push({ type: "text_end", contentIndex: 0, content: "done", partial: done }); - stream.push({ type: "done", reason: "stop", message: done }); - }); - return stream; - } - - queueMicrotask(async () => { - firstRequestSignal = options?.signal; - stream.push({ type: "start", partial: makeMessage(0) }); - for (let index = 1; index <= 10; index++) { - if (options?.signal?.aborted) { - const aborted = createAssistantMessage([], "aborted"); - stream.push({ type: "error", reason: "aborted", error: aborted }); - return; - } - const partial = makeMessage(index); - const toolCall = partial.content[index - 1]; - if (toolCall?.type !== "toolCall") throw new Error("Expected tool call"); - stream.push({ type: "toolcall_start", contentIndex: index - 1, partial }); - stream.push({ - type: "toolcall_delta", - contentIndex: index - 1, - delta: JSON.stringify(toolCall.arguments), - partial, - }); - stream.push({ type: "toolcall_end", contentIndex: index - 1, toolCall, partial }); - await Bun.sleep(0); - } - stream.push({ type: "done", reason: "toolUse", message: makeMessage(10, "toolUse") }); - }); - return stream; - }; - - const config: AgentLoopConfig = { - model: mock.model, - convertToLlm: identityConverter, - maxToolCallsPerTurn: 8, - }; - - const events: AgentEvent[] = []; - const stream = agentLoop([createUserMessage("echo many")], context, config, undefined, streamFn); - for await (const event of stream) { - events.push(event); - } - - expect(executed).toEqual(["1", "2", "3", "4", "5", "6", "7", "8"]); - expect(firstRequestSignal?.aborted).toBe(true); - expect(modelCalls).toBe(2); - - const batchedTurn = events.find( - (event): event is Extract => - event.type === "turn_end" && event.toolResults.length === 8, - ); - expect(batchedTurn).toBeDefined(); - if (batchedTurn?.message.role !== "assistant") return; - expect(batchedTurn.message.stopReason).toBe("toolUse"); - expect(batchedTurn.message.content.filter(block => block.type === "toolCall")).toHaveLength(8); - expect(batchedTurn.toolResults.map(result => result.toolCallId).sort()).toEqual([ - "tool-1", - "tool-2", - "tool-3", - "tool-4", - "tool-5", - "tool-6", - "tool-7", - "tool-8", - ]); - }); - it("injects and strips intent when intent tracing is enabled", async () => { const toolSchema = z.object({ value: z.string() }); const executedParams: Record[] = []; @@ -630,7 +569,7 @@ describe("agentLoop with AgentMessage", () => { expect(turnEndEvent.toolResults.map(result => result.toolCallId)).toEqual(["tool-2", "tool-1"]); }); - it("emits an explicit warning toolResult when assistant aborts after issuing tool calls", async () => { + it("drops incomplete tool calls when assistant aborts before toolcall_end", async () => { const context: AgentContext = { systemPrompt: ["You are helpful."], messages: [], @@ -641,8 +580,10 @@ describe("agentLoop with AgentMessage", () => { const mock = createMockModel(); const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter }; - // Custom stream: emit a partial start with a tool call, abort, then push done. - // The mock provider doesn't model "abort between start and done"; do it inline. + // Custom stream: emit a partial assistant that already contains a tool + // call, then abort before any `toolcall_end` event proves that the args + // completed. The agent must not synthesize a toolResult for that partial + // call; replaying it would preserve unsafe/incomplete arguments. const streamFn = () => { const stream = new AssistantMessageEventStream(); queueMicrotask(() => { @@ -669,16 +610,16 @@ describe("agentLoop with AgentMessage", () => { (e): e is Extract => e.type === "message_end" && e.message.role === "toolResult", ); - expect(toolResultEvent).toBeDefined(); - if (toolResultEvent?.message.role !== "toolResult") return; - expect(toolResultEvent.message.isError).toBe(true); - expect(toolResultEvent.message.toolCallId).toBe("tool-1"); - expect(toolResultEvent.message.content[0]?.type).toBe("text"); - if (toolResultEvent.message.content[0]?.type === "text") { - const text = toolResultEvent.message.content[0].text; - expect(text).toContain("Tool execution was aborted"); - expect(text).not.toContain("Tool execution was aborted.:"); - } + expect(toolResultEvent).toBeUndefined(); + + const assistantEnd = events.find( + (e): e is Extract => + e.type === "message_end" && e.message.role === "assistant", + ); + expect(assistantEnd).toBeDefined(); + if (assistantEnd?.message.role !== "assistant") return; + expect(assistantEnd.message.stopReason).toBe("aborted"); + expect(assistantEnd.message.content.some(block => block.type === "toolCall")).toBe(false); }); it("should skip remaining tool calls when steering is queued", async () => { @@ -1188,6 +1129,55 @@ describe("agentLoopContinue with AgentMessage", () => { } }); + it("runs afterToolCall for a completed result even when the run aborts before the hook", async () => { + const toolSchema = z.object({ value: z.string() }); + const controller = new AbortController(); + const tool: AgentTool = { + name: "echo", + label: "Echo", + description: "Echo tool", + parameters: toolSchema, + async execute(_toolCallId, params) { + controller.abort("stop after tool"); + return { + content: [{ type: "text", text: `original: ${params.value}` }], + details: { value: params.value }, + }; + }, + }; + + const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] }; + let hookSawAbortedSignal = false; + const mock = createMockModel({ + responses: [ + { content: [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }] }, + { content: ["done"] }, + ], + }); + const config: AgentLoopConfig = { + model: mock.model, + convertToLlm: identityConverter, + afterToolCall: async (_context, signal) => { + hookSawAbortedSignal = signal?.aborted === true; + return { content: [{ type: "text", text: "rewritten after abort" }] }; + }, + }; + + const events: AgentEvent[] = []; + const stream = agentLoop([createUserMessage("echo")], context, config, controller.signal, mock.stream); + for await (const event of stream) { + events.push(event); + } + + expect(hookSawAbortedSignal).toBe(true); + const toolEnd = events.find(e => e.type === "tool_execution_end"); + expect(toolEnd).toBeDefined(); + if (toolEnd?.type === "tool_execution_end") { + expect(toolEnd.isError).toBe(false); + expect(toolEnd.result.content).toEqual([{ type: "text", text: "rewritten after abort" }]); + } + }); + it("surfaces afterToolCall errors as a tool error result", async () => { const toolSchema = z.object({ value: z.string() }); const tool: AgentTool = { @@ -1307,6 +1297,9 @@ describe("agentLoopContinue with AgentMessage", () => { ], stopReason: "length", }, + { + content: ["ok, I will split the write into smaller chunks"], + }, ], }); const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter }; diff --git a/packages/agent/test/append-only-context.test.ts b/packages/agent/test/append-only-context.test.ts index 945fe4cac..b46988663 100644 --- a/packages/agent/test/append-only-context.test.ts +++ b/packages/agent/test/append-only-context.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; +import { AppendOnlyContextManager, AppendOnlyLog, StablePrefix } from "@oh-my-pi/pi-agent-core/append-only-context"; +import type { AgentContext, AgentTool } from "@oh-my-pi/pi-agent-core/types"; import type { Message, Tool } from "@oh-my-pi/pi-ai"; -import { AppendOnlyContextManager, AppendOnlyLog, StablePrefix } from "../src/append-only-context"; -import type { AgentContext, AgentTool } from "../src/types"; // --------------------------------------------------------------------------- // Helpers diff --git a/packages/agent/test/harmony-leak.test.ts b/packages/agent/test/harmony-leak.test.ts index bd3d785c6..9588ec570 100644 --- a/packages/agent/test/harmony-leak.test.ts +++ b/packages/agent/test/harmony-leak.test.ts @@ -1,6 +1,4 @@ import { describe, expect, it } from "bun:test"; -import type { AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai"; -import { getBundledModel } from "@oh-my-pi/pi-ai"; import { createHarmonyAuditEvent, detectHarmonyLeak, @@ -9,7 +7,9 @@ import { isHarmonyLeakMitigationTarget, recoverHarmonyToolCall, signalListLabel, -} from "../src/harmony-leak"; +} from "@oh-my-pi/pi-agent-core/harmony-leak"; +import type { AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-ai"; import corpus from "./fixtures/harmony-leak-corpus.json" with { type: "json" }; import { createAssistantMessage } from "./helpers"; diff --git a/packages/agent/test/run-summary.test.ts b/packages/agent/test/run-summary.test.ts index 6b6511b28..6629ae41c 100644 --- a/packages/agent/test/run-summary.test.ts +++ b/packages/agent/test/run-summary.test.ts @@ -503,37 +503,38 @@ describe("aggregateAgentRunSummaries / aggregateAgentRunCoverage", () => { describe("onRunEnd is non-fatal", () => { it("swallows thrown errors and still resolves agentLoop().result() normally", async () => { const tracer = new RecordingTracer(); - const warnings: unknown[][] = []; - const realWarn = console.warn; - console.warn = (...args: unknown[]) => { - warnings.push(args); - }; - try { - const mock = createMockModel({ responses: [{ content: ["ok"] }] }); - const stream = agentLoop( - [createUserMessage("hi")], - { systemPrompt: ["sys"], messages: [], tools: [] }, - { - model: mock.model, - convertToLlm: identityConverter, - telemetry: { - tracer, - onRunEnd: () => { - throw new Error("user code is buggy"); - }, + const warnings: { code: string; message: string }[] = []; + const mock = createMockModel({ responses: [{ content: ["ok"] }] }); + const stream = agentLoop( + [createUserMessage("hi")], + { systemPrompt: ["sys"], messages: [], tools: [] }, + { + model: mock.model, + convertToLlm: identityConverter, + telemetry: { + tracer, + onRunEnd: () => { + throw new Error("user code is buggy"); + }, + // The failure is surfaced through the telemetry-warning channel, not a + // rejection. `console.warn` is only the no-hook fallback inside + // `emitTelemetryWarning`, so capture via the hook for a deterministic assert. + onTelemetryWarning: warning => { + warnings.push({ code: warning.code, message: warning.message }); }, }, - undefined, - mock.stream, - ); - const messages = await stream.result(); - expect(messages.length).toBe(2); - } finally { - console.warn = realWarn; - } - // The wrapper must surface the failure via console.warn, not via rejection. + }, + undefined, + mock.stream, + ); + const messages = await stream.result(); + expect(messages.length).toBe(2); + // `fireOnRunEnd` can run in `runLoop`'s finally, after `result()` resolves — flush + // the trailing microtasks of the fire-and-forget agent loop before asserting. + await Bun.sleep(5); + expect(warnings.length).toBeGreaterThanOrEqual(1); - expect(String(warnings[0][0])).toContain("onRunEnd"); + expect(warnings.some(w => w.code === "on_run_end_failed" && w.message.includes("onRunEnd"))).toBe(true); }); }); diff --git a/packages/agent/test/yield.test.ts b/packages/agent/test/yield.test.ts index f5d89ad4b..abb6142d0 100644 --- a/packages/agent/test/yield.test.ts +++ b/packages/agent/test/yield.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import { scheduler } from "node:timers/promises"; -import { ExponentialYield, yieldIfDue } from "../src/utils/yield"; +import { ExponentialYield, yieldIfDue } from "@oh-my-pi/pi-agent-core/utils/yield"; const YIELD_INTERVAL_MS = 50; const YIELD_CLOCK_STEP_MS = 60_000; diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 1ebaa4a4d..b19208ed5 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,7 +2,36 @@ ## [Unreleased] +## [15.10.5] - 2026-06-08 + +### Breaking Changes + +- Renamed the OAuth subpath export `@oh-my-pi/pi-ai/utils/oauth` → `@oh-my-pi/pi-ai/oauth` (and `@oh-my-pi/pi-ai/utils/oauth/*` → `@oh-my-pi/pi-ai/oauth/*`, e.g. `oauth/types`, `oauth/callback-server`, `oauth/openai-codex`) after relocating the OAuth implementation out of `utils/oauth/` into `registry/oauth/`. The high-level OAuth API (`getOAuthProviders`, `refreshOAuthToken`, `getOAuthApiKey`, `registerOAuthProvider`, `unregisterOAuthProviders`, `getOAuthProvider`) and the `OAuth*` types stay exported from the package root, unchanged. + +### Changed + +- Changed Anthropic retry handling to avoid retrying 4xx responses other than 408 and 429 +- Optimized the Anthropic `cch` attestation patch to locate the billing-header placeholder with native `Buffer.indexOf` (memmem) instead of a hand-rolled byte loop. The marker sits ~99% through the body (`messages` serializes before `system`), so the old scan walked almost the entire payload; output bytes are unchanged but the patch is ~7.5x faster (563µs -> 75µs on a 1MB body). +- Refactored provider configuration to a single-source registry (`registry/`, renamed from `provider-registry/` with its `providers/` subdir flattened up). The `KnownProvider`/`OAuthProvider` type unions, `PROVIDER_DESCRIPTORS`, `DEFAULT_MODEL_PER_PROVIDER`, the `serviceProviderMap` env-key fallbacks, the `/login` provider list (`builtInOAuthProviders`), and the `refreshOAuthToken`/`AuthStorage.login` dispatch are all derived from it. Provider defs live directly under `registry/`; thin provider-specific login flows are inlined into the def file, while heavier provider-local OAuth flows and the shared OAuth flow infra (`callback-server`, `pkce`, `google-oauth-shared`, `types`, runtime `index`) now live together under `registry/oauth/` (previously split across `provider-registry/providers/oauth/` and `utils/oauth/`). The non-OAuth API-key paste/validation helpers (`api-key-login`, `api-key-validation`) sit beside the defs in `registry/`. Adding a provider that reuses an existing wire API is now one new provider def plus one registry entry in the common case. Exposes `PROVIDER_REGISTRY`, `getProviderDefinition`, `ProviderDefinition`, and `PASTE_CODE_LOGIN_PROVIDERS`. + +### Fixed + +- Disabled OpenAI Codex Responses stream obfuscation by sending `stream_options.include_obfuscation=false`, reducing raw WebSocket/SSE debug noise and bandwidth. +- Interrupted OpenAI Codex Responses streams that emit long runs of whitespace-only tool-call argument deltas, preventing degenerate WebSocket/SSE responses from filling the raw stream buffer indefinitely. +- Preserved streaming responses when Anthropic emits unrecognized content_block envelopes by ignoring unknown blocks and continuing to emit known content +- Applied cache control to the most recent tool result block when building Anthropic OAuth payloads without a preceding text block, enabling ephemeral caching for tool-result-only messages +- Kept Anthropic sampling parameters (temperature, top_p, top_k) when thinking is explicitly disabled +- Fixed raw Anthropic SSE handling by parsing event frames with strict JSON parsing and matching event-type validation, surfacing malformed frames as stream errors instead of repairing them +- Fixed Anthropic stream envelope handling to reject duplicate `content_block_start` indexes and block deltas/stops for unopened blocks, preventing malformed envelope states from producing partial output +- Fixed Anthropic image conversion to normalize `image/jpg` to `image/jpeg` and emit a placeholder for unsupported image MIME types +- Fixed Anthropic thinking request preparation by clamping `max_tokens` to provider/model limits and adjusting thinking budgets to a valid value +- Fixed Anthropic request shaping around forced tool choice, unsigned thinking replay, prompt-cache marker placement, non-Anthropic bearer gateways, Foundry TLS loading, and strict tool-schema normalization so malformed or incompatible request payloads are rejected locally or shaped consistently before streaming +- Fixed the Anthropic stream parser shipping a truncated tool call as a completed turn. When a transport drop cut the SSE stream mid-`tool_use` and a transparent reconnect spliced a fresh message envelope onto the same stream, the duplicate `message_start` was deduped but the orphaned tool block — which never received its `content_block_stop` — survived in the assistant message with its seed `{}` (or partially-parsed) arguments. The terminal stop signal from the reconnect then let it flow through as a normal tool call, so e.g. a `read` dispatched with `{}` failed downstream validation (`path: expected string, received undefined`). The parser now treats any tool block left open at stream end as a truncated envelope and routes it through the existing retry/error path instead of emitting bogus arguments. +- Fixed the Zhipu Coding Plan login prompt advertising a misleading `sk-...` placeholder. Zhipu API keys are formatted `.` (no `sk-` prefix), so the placeholder now matches the actual format instead of suggesting the wrong shape. ([#2106](https://github.com/can1357/oh-my-pi/issues/2106)) +- Fixed Moonshot `kimi-k2.6` (and any future `kimi-k2.x`) discovered via `MOONSHOT_API_KEY` stalling on first turn with no output. The `moonshotModelManagerOptions` discovery mapper only marked ids containing `"thinking"` as `reasoning: true`, so dynamic `kimi-k2.6` entries fell through with `reasoning: false`; the openai-completions z.ai branch was then skipped and the request reached Moonshot with no `thinking` parameter at all. Moonshot K2.6 requires an explicit `thinking: {type}` field (the same native-API wire shape #1838 introduced `thinking.keep` for), so the server held the stream silently. The mapper now stamps `reasoning: true`, vision input, and default `thinking` metadata on every `kimi-k2.x` id, restoring the explicit `thinking: {type: "disabled"|"enabled"}` wire body the Moonshot endpoint expects. ([#2113](https://github.com/can1357/oh-my-pi/issues/2113)) + ## [15.10.4] - 2026-06-08 + ### Added - Added `anthropic-client-platform` (`desktop_app`) and `anthropic-client-version` (`1.11187.4`) headers to the Anthropic request fingerprint for OAuth sessions @@ -20,8 +49,8 @@ - Removed the synthetic `` developer guidance note that `transformMessages` injected after an aborted/errored assistant turn (and its `turn-aborted-guidance.md` prompt). The per-call synthetic `"aborted"` tool results already tell the model the turn's tools were terminated, so the extra "verify current state before retrying" note was redundant — and it biased the model toward second-guessing a deliberate user interrupt when the turn was resumed. - Removed the legacy Anthropic first-user-message skip for `` blocks now that synthetic reminders no longer travel as user messages. - ## [15.10.2] - 2026-06-08 + ### Added - Added support for `impersonated_service_account` Application Default Credentials (ADC) in Vertex AI to enable chained impersonation without failing via 401 `invalid_client`. @@ -3045,4 +3074,4 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_ ## [0.9.4] - 2025-11-26 -Initial release with multi-provider LLM support. \ No newline at end of file +Initial release with multi-provider LLM support. diff --git a/packages/ai/package.json b/packages/ai/package.json index 436187a7c..019bc27cd 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-ai", - "version": "15.10.4", + "version": "15.10.5", "description": "Unified LLM API with automatic model discovery and provider configuration", "homepage": "https://omp.sh", "author": "Can Boluk", @@ -120,13 +120,21 @@ "types": "./src/utils/discovery/*.ts", "import": "./src/utils/discovery/*.ts" }, - "./utils/oauth": { - "types": "./src/utils/oauth/index.ts", - "import": "./src/utils/oauth/index.ts" + "./oauth": { + "types": "./src/registry/oauth/index.ts", + "import": "./src/registry/oauth/index.ts" }, - "./utils/oauth/*": { - "types": "./src/utils/oauth/*.ts", - "import": "./src/utils/oauth/*.ts" + "./oauth/*": { + "types": "./src/registry/oauth/*.ts", + "import": "./src/registry/oauth/*.ts" + }, + "./registry": { + "types": "./src/registry/index.ts", + "import": "./src/registry/index.ts" + }, + "./registry/oauth": { + "types": "./src/registry/oauth/index.ts", + "import": "./src/registry/oauth/index.ts" }, "./utils/schema": { "types": "./src/utils/schema/index.ts", diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 497fb7c25..a2a49f251 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -38,10 +38,10 @@ import { } from "../src/provider-models/openai-compat"; import { getGitLabDuoModels } from "../src/providers/gitlab-duo"; import { JWT_CLAIM_PATH } from "../src/providers/openai-codex/constants"; +import type { OAuthProvider } from "../src/registry/oauth/types"; import type { Model } from "../src/types"; import { fetchAntigravityDiscoveryModels } from "../src/utils/discovery/antigravity"; import { fetchCodexModels } from "../src/utils/discovery/codex"; -import type { OAuthProvider } from "../src/utils/oauth/types"; const packageRoot = path.join(import.meta.dir, ".."); diff --git a/packages/ai/src/auth-broker/remote-store.ts b/packages/ai/src/auth-broker/remote-store.ts index c0f9f17a3..9523b832e 100644 --- a/packages/ai/src/auth-broker/remote-store.ts +++ b/packages/ai/src/auth-broker/remote-store.ts @@ -17,9 +17,9 @@ import { REMOTE_REFRESH_SENTINEL, type StoredAuthCredential, } from "../auth-storage"; +import type { OAuthCredentials } from "../registry/oauth/types"; import type { Provider } from "../types"; import type { UsageReport } from "../usage"; -import type { OAuthCredentials } from "../utils/oauth/types"; import { type AuthBrokerClient, AuthBrokerStreamUnsupportedError } from "./client"; import type { RefresherSchedule, SnapshotEntry, SnapshotResponse, SnapshotStreamEvent } from "./types"; diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 49a3843ba..d61c4a3fe 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -13,6 +13,9 @@ import * as path from "node:path"; import { getAgentDbPath, logger } from "@oh-my-pi/pi-utils"; import type { ApiKeyResolver } from "./auth-retry"; import { isUsageLimitError } from "./rate-limit-utils"; +import { getProviderDefinition } from "./registry"; +import { getOAuthApiKey, getOAuthProvider, refreshOAuthToken } from "./registry/oauth"; +import type { OAuthController, OAuthCredentials, OAuthProvider, OAuthProviderId } from "./registry/oauth/types"; import { getEnvApiKey, getEnvApiKeyName } from "./stream"; import type { Provider } from "./types"; import type { @@ -32,11 +35,6 @@ import { antigravityUsageProvider } from "./usage/google-antigravity"; import { kimiUsageProvider } from "./usage/kimi"; import { codexRankingStrategy, openaiCodexUsageProvider } from "./usage/openai-codex"; import { zaiUsageProvider } from "./usage/zai"; -import { getOAuthApiKey, getOAuthProvider, refreshOAuthToken } from "./utils/oauth"; -import { loginDeepSeek } from "./utils/oauth/deepseek"; -import { loginOpenAICodexDevice } from "./utils/oauth/openai-codex"; -import type { OAuthController, OAuthCredentials, OAuthProvider, OAuthProviderId } from "./utils/oauth/types"; -import { loginXiaomi, loginXiaomiTokenPlan } from "./utils/oauth/xiaomi"; const USAGE_RANKING_METRIC_EPSILON = 1e-9; @@ -1554,345 +1552,33 @@ export class AuthStorage { onPrompt: (prompt: { message: string; placeholder?: string }) => Promise; }, ): Promise { - let credentials: OAuthCredentials; const saveApiKeyCredential = async (apiKey: string): Promise => { const newCredential: ApiKeyCredential = { type: "api_key", key: apiKey }; await this.set(provider, newCredential); }; const manualCodeInput = () => ctrl.onPrompt({ message: "Paste the authorization code (or full redirect URL):" }); - switch (provider) { - case "anthropic": { - const { loginAnthropic } = await import("./utils/oauth/anthropic"); - credentials = await loginAnthropic({ - ...ctrl, - onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput, - }); - break; - } - case "xai-oauth": { - const { loginXAIOAuth } = await import("./utils/oauth/xai-oauth"); - credentials = await loginXAIOAuth({ - ...ctrl, - onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput, - }); - break; - } - case "alibaba-coding-plan": { - const { loginAlibabaCodingPlan } = await import("./utils/oauth/alibaba-coding-plan"); - const apiKey = await loginAlibabaCodingPlan(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "github-copilot": { - const { loginGitHubCopilot } = await import("./utils/oauth/github-copilot"); - credentials = await loginGitHubCopilot({ - onAuth: (url, instructions) => ctrl.onAuth({ url, instructions }), - onPrompt: ctrl.onPrompt, - onProgress: ctrl.onProgress, - signal: ctrl.signal, - }); - break; - } - case "google-gemini-cli": { - const { loginGeminiCli } = await import("./utils/oauth/google-gemini-cli"); - credentials = await loginGeminiCli({ - ...ctrl, - onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput, - }); - break; - } - case "google-antigravity": { - const { loginAntigravity } = await import("./utils/oauth/google-antigravity"); - credentials = await loginAntigravity({ - ...ctrl, - onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput, - }); - break; - } - case "openai-codex": { - const { loginOpenAICodex } = await import("./utils/oauth/openai-codex"); - credentials = await loginOpenAICodex({ - ...ctrl, - onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput, - }); - break; - } - case "openai-codex-device": { - // Device/headless flow — stores credentials under "openai-codex" so the - // provider can pick them up without a separate provider configuration. - const deviceCredentials = await loginOpenAICodexDevice(ctrl); - const newCredential: OAuthCredential = { type: "oauth", ...deviceCredentials }; - await this.#upsertOAuthCredential("openai-codex", newCredential); - return; - } - case "gitlab-duo": { - const { loginGitLabDuo } = await import("./utils/oauth/gitlab-duo"); - credentials = await loginGitLabDuo({ - ...ctrl, - onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput, - }); - break; - } - case "kimi-code": { - const { loginKimi } = await import("./utils/oauth/kimi"); - credentials = await loginKimi(ctrl); - break; - } - case "kilo": { - const { loginKilo } = await import("./utils/oauth/kilo"); - credentials = await loginKilo(ctrl); - break; - } - case "cursor": { - const { loginCursor } = await import("./utils/oauth/cursor"); - credentials = await loginCursor( - url => ctrl.onAuth({ url }), - ctrl.onProgress ? () => ctrl.onProgress?.("Waiting for browser authentication...") : undefined, - ); - break; - } - case "perplexity": { - const { loginPerplexity } = await import("./utils/oauth/perplexity"); - credentials = await loginPerplexity(ctrl); - break; - } - case "huggingface": { - const { loginHuggingface } = await import("./utils/oauth/huggingface"); - const apiKey = await loginHuggingface(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "opencode-zen": - case "opencode-go": { - const { loginOpenCode } = await import("./utils/oauth/opencode"); - const apiKey = await loginOpenCode(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "lm-studio": { - const { loginLmStudio } = await import("./utils/oauth/lm-studio"); - const apiKey = await loginLmStudio(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "ollama": { - const { loginOllama } = await import("./utils/oauth/ollama"); - const apiKey = await loginOllama(ctrl); - if (!apiKey) { - return; - } - await saveApiKeyCredential(apiKey); - return; - } - case "ollama-cloud": { - const { loginOllamaCloud } = await import("./utils/oauth/ollama-cloud"); - const apiKey = await loginOllamaCloud(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "cerebras": { - const { loginCerebras } = await import("./utils/oauth/cerebras"); - const apiKey = await loginCerebras(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "deepseek": { - const apiKey = await loginDeepSeek(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "fireworks": { - const { loginFireworks } = await import("./utils/oauth/fireworks"); - const apiKey = await loginFireworks(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "firepass": { - const { loginFirepass } = await import("./utils/oauth/firepass"); - const apiKey = await loginFirepass(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "wafer-pass": { - const { loginWaferPass } = await import("./utils/oauth/wafer"); - const apiKey = await loginWaferPass(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "wafer-serverless": { - const { loginWaferServerless } = await import("./utils/oauth/wafer"); - const apiKey = await loginWaferServerless(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "zai": { - const { loginZai } = await import("./utils/oauth/zai"); - const apiKey = await loginZai(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "zhipu-coding-plan": { - const { loginZhipuCodingPlan } = await import("./utils/oauth/zhipu"); - const apiKey = await loginZhipuCodingPlan(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "qianfan": { - const { loginQianfan } = await import("./utils/oauth/qianfan"); - const apiKey = await loginQianfan(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "minimax-code": { - const { loginMiniMaxCode } = await import("./utils/oauth/minimax-code"); - const apiKey = await loginMiniMaxCode(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "minimax-code-cn": { - const { loginMiniMaxCodeCn } = await import("./utils/oauth/minimax-code"); - const apiKey = await loginMiniMaxCodeCn(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "synthetic": { - const { loginSynthetic } = await import("./utils/oauth/synthetic"); - const apiKey = await loginSynthetic(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "tavily": { - const { loginTavily } = await import("./utils/oauth/tavily"); - const apiKey = await loginTavily(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "venice": { - const { loginVenice } = await import("./utils/oauth/venice"); - const apiKey = await loginVenice(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "litellm": { - const { loginLiteLLM } = await import("./utils/oauth/litellm"); - const apiKey = await loginLiteLLM(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "moonshot": { - const { loginMoonshot } = await import("./utils/oauth/moonshot"); - const apiKey = await loginMoonshot(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "kagi": { - const { loginKagi } = await import("./utils/oauth/kagi"); - const apiKey = await loginKagi(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "nanogpt": { - const { loginNanoGPT } = await import("./utils/oauth/nanogpt"); - const apiKey = await loginNanoGPT(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "openrouter": { - const { loginOpenRouter } = await import("./utils/oauth/openrouter"); - const apiKey = await loginOpenRouter(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "together": { - const { loginTogether } = await import("./utils/oauth/together"); - const apiKey = await loginTogether(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "cloudflare-ai-gateway": { - const { loginCloudflareAiGateway } = await import("./utils/oauth/cloudflare-ai-gateway"); - const apiKey = await loginCloudflareAiGateway(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "vercel-ai-gateway": { - const { loginVercelAiGateway } = await import("./utils/oauth/vercel-ai-gateway"); - const apiKey = await loginVercelAiGateway(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "vllm": { - const { loginVllm } = await import("./utils/oauth/vllm"); - const apiKey = await loginVllm(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "parallel": { - const { loginParallel } = await import("./utils/oauth/parallel"); - const apiKey = await loginParallel(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "qwen-portal": { - const { loginQwenPortal } = await import("./utils/oauth/qwen-portal"); - const apiKey = await loginQwenPortal(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "nvidia": { - const { loginNvidia } = await import("./utils/oauth/nvidia"); - const apiKey = await loginNvidia(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "xiaomi": { - const apiKey = await loginXiaomi(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "xiaomi-token-plan-sgp": { - const apiKey = await loginXiaomiTokenPlan(ctrl, "sgp"); - await saveApiKeyCredential(apiKey); - return; - } - case "xiaomi-token-plan-ams": { - const apiKey = await loginXiaomiTokenPlan(ctrl, "ams"); - await saveApiKeyCredential(apiKey); - return; - } - case "xiaomi-token-plan-cn": { - const apiKey = await loginXiaomiTokenPlan(ctrl, "cn"); - await saveApiKeyCredential(apiKey); - return; - } - case "zenmux": { - const { loginZenMux } = await import("./utils/oauth/zenmux"); - const apiKey = await loginZenMux(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - default: { - const customProvider = getOAuthProvider(provider); - if (!customProvider) { - throw new Error(`Unknown OAuth provider: ${provider}`); - } - const customLoginResult = await customProvider.login({ - onAuth: info => ctrl.onAuth(info), - onProgress: ctrl.onProgress, - onPrompt: ctrl.onPrompt, - onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput, - signal: ctrl.signal, - }); - if (typeof customLoginResult === "string") { - await saveApiKeyCredential(customLoginResult); - return; - } - credentials = customLoginResult; - break; - } + // Built-in registry first, then runtime-registered extension providers. + const def = getProviderDefinition(provider) ?? getOAuthProvider(provider); + if (!def?.login) { + throw new Error(`Unknown OAuth provider: ${provider}`); } - const newCredential: OAuthCredential = { type: "oauth", ...credentials }; - await this.#upsertOAuthCredential(provider, newCredential); + const result = await def.login({ + onAuth: ctrl.onAuth, + onProgress: ctrl.onProgress, + onPrompt: ctrl.onPrompt, + onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput, + signal: ctrl.signal, + }); + if (typeof result === "string") { + // Some flows (e.g. ollama) return "" to signal that no key was entered. + if (!result) { + return; + } + await saveApiKeyCredential(result); + return; + } + const newCredential: OAuthCredential = { type: "oauth", ...result }; + await this.#upsertOAuthCredential(def.storeCredentialsAs ?? provider, newCredential); } /** diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index a9d3c724a..7209e8145 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -29,6 +29,7 @@ export * from "./providers/openai-completions"; export * from "./providers/openai-responses"; export * from "./providers/synthetic"; export * from "./rate-limit-utils"; +export * from "./registry"; export * from "./stream"; export * from "./types"; export * from "./usage"; @@ -43,13 +44,6 @@ export * from "./usage/zai"; export * from "./utils/anthropic-auth"; export * from "./utils/discovery"; export * from "./utils/event-stream"; -export * from "./utils/oauth"; -export type { - OAuthCredentials, - OAuthProvider, - OAuthProviderId, - OAuthProviderInfo, -} from "./utils/oauth/types"; export * from "./utils/overflow"; export * from "./utils/retry"; export * from "./utils/schema"; diff --git a/packages/ai/src/models.json b/packages/ai/src/models.json index f6dc1a083..78b211331 100644 --- a/packages/ai/src/models.json +++ b/packages/ai/src/models.json @@ -38413,9 +38413,9 @@ "image" ], "cost": { - "input": 0.6, - "output": 2.4, - "cacheRead": 0.12, + "input": 0.3, + "output": 1.2, + "cacheRead": 0.06, "cacheWrite": 0 }, "contextWindow": 512000, @@ -40159,13 +40159,13 @@ "image" ], "cost": { - "input": 0.684, - "output": 3.42, - "cacheRead": 0.144, + "input": 0.6799999999999999, + "output": 3.41, + "cacheRead": 0.33999999999999997, "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 262144, + "maxTokens": 262142, "thinking": { "mode": "effort", "minLevel": "minimal", @@ -43304,13 +43304,13 @@ "image" ], "cost": { - "input": 0.684, - "output": 3.42, - "cacheRead": 0.144, + "input": 0.6799999999999999, + "output": 3.41, + "cacheRead": 0.33999999999999997, "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 262144, + "maxTokens": 262142, "thinking": { "mode": "effort", "minLevel": "minimal", diff --git a/packages/ai/src/provider-models/descriptors.ts b/packages/ai/src/provider-models/descriptors.ts index e78168c99..5685eccb9 100644 --- a/packages/ai/src/provider-models/descriptors.ts +++ b/packages/ai/src/provider-models/descriptors.ts @@ -1,364 +1,43 @@ /** - * Unified provider descriptors — single source of truth for provider metadata - * used by both runtime model discovery (model-registry.ts) and catalog - * generation (generate-models.ts). + * Provider descriptors and the default-model map, derived from the single-source + * provider registry (`../registry`). + * + * The descriptor/catalog types and guards now live in the registry; they are + * re-exported here for back-compat with `generate-models.ts` and existing + * `@oh-my-pi/pi-ai/provider-models` consumers. */ -import type { ModelManagerOptions } from "../model-manager"; -import type { Api, KnownProvider } from "../types"; -import type { OAuthProvider } from "../utils/oauth/types"; -import { googleModelManagerOptions, googleVertexModelManagerOptions } from "./google"; -import { ollamaCloudModelManagerOptions } from "./ollama"; -import { - alibabaCodingPlanModelManagerOptions, - anthropicModelManagerOptions, - cerebrasModelManagerOptions, - cloudflareAiGatewayModelManagerOptions, - deepseekModelManagerOptions, - firepassModelManagerOptions, - fireworksModelManagerOptions, - githubCopilotModelManagerOptions, - groqModelManagerOptions, - huggingfaceModelManagerOptions, - kiloModelManagerOptions, - kimiCodeModelManagerOptions, - litellmModelManagerOptions, - lmStudioModelManagerOptions, - mistralModelManagerOptions, - moonshotModelManagerOptions, - nanoGptModelManagerOptions, - nvidiaModelManagerOptions, - ollamaModelManagerOptions, - openaiModelManagerOptions, - opencodeGoModelManagerOptions, - opencodeZenModelManagerOptions, - openrouterModelManagerOptions, - qianfanModelManagerOptions, - qwenPortalModelManagerOptions, - syntheticModelManagerOptions, - togetherModelManagerOptions, - veniceModelManagerOptions, - vercelAiGatewayModelManagerOptions, - vllmModelManagerOptions, - waferPassModelManagerOptions, - waferServerlessModelManagerOptions, - xaiModelManagerOptions, - xaiOAuthModelManagerOptions, - xiaomiModelManagerOptions, - zenmuxModelManagerOptions, - zhipuCodingPlanModelManagerOptions, -} from "./openai-compat"; -import { cursorModelManagerOptions, zaiModelManagerOptions } from "./special"; +import { PROVIDER_REGISTRY } from "../registry"; +import type { ProviderDescriptor } from "../registry/types"; +import type { KnownProvider } from "../types"; -/** Catalog discovery configuration for providers that support endpoint-based model listing. */ -export interface CatalogDiscoveryConfig { - /** Human-readable name for log messages. */ - label: string; - /** Environment variables to check for API keys during catalog generation. */ - envVars: string[]; - /** OAuth provider for credential refresh during catalog generation. */ - oauthProvider?: OAuthProvider; - /** When true, catalog discovery proceeds even without credentials. */ - allowUnauthenticated?: boolean; -} - -/** Unified provider descriptor used by both runtime discovery and catalog generation. */ -export interface ProviderDescriptor { - providerId: KnownProvider; - createModelManagerOptions(config: { apiKey?: string; baseUrl?: string }): ModelManagerOptions; - /** Preferred model ID when no explicit selection is made. */ - defaultModel: string; - /** When true, the runtime creates a model manager even without a valid API key (e.g. ollama). */ - allowUnauthenticated?: boolean; - /** When true, successful runtime discovery replaces bundled provider models instead of merging fallback-only IDs. */ - dynamicModelsAuthoritative?: boolean; - /** Catalog discovery configuration. Only providers with this field participate in generate-models.ts. */ - catalogDiscovery?: CatalogDiscoveryConfig; -} - -/** A provider descriptor that has catalog discovery configured. */ -export type CatalogProviderDescriptor = ProviderDescriptor & { catalogDiscovery: CatalogDiscoveryConfig }; - -/** Type guard for descriptors with catalog discovery. */ -export function isCatalogDescriptor(d: ProviderDescriptor): d is CatalogProviderDescriptor { - return d.catalogDiscovery != null; -} - -/** Whether catalog discovery may run without provider credentials. */ -export function allowsUnauthenticatedCatalogDiscovery(descriptor: CatalogProviderDescriptor): boolean { - return descriptor.catalogDiscovery.allowUnauthenticated ?? descriptor.allowUnauthenticated ?? false; -} - -function descriptor( - providerId: KnownProvider, - defaultModel: string, - createModelManagerOptions: ProviderDescriptor["createModelManagerOptions"], - options: Pick = {}, -): ProviderDescriptor { - return { - providerId, - defaultModel, - createModelManagerOptions, - ...options, - }; -} - -function catalog( - label: string, - envVars: string[], - options: Pick = {}, -): CatalogDiscoveryConfig { - return { - label, - envVars, - ...options, - }; -} - -function catalogDescriptor( - providerId: KnownProvider, - defaultModel: string, - createModelManagerOptions: ProviderDescriptor["createModelManagerOptions"], - catalogDiscovery: CatalogDiscoveryConfig, - options: Pick = {}, -): ProviderDescriptor { - return { - ...descriptor(providerId, defaultModel, createModelManagerOptions, options), - catalogDiscovery, - }; -} +export * from "../registry/types"; /** - * All standard providers. Special providers (google-antigravity, google-gemini-cli, - * openai-codex) are handled separately because they require different config shapes. + * Runtime model-discovery descriptors: every registry provider that exposes a + * standard model-manager factory. Special-managed providers + * (`google-antigravity`/`google-gemini-cli`/`openai-codex`) are built bespoke in + * the coding-agent runtime and are excluded here. */ -export const PROVIDER_DESCRIPTORS: readonly ProviderDescriptor[] = [ - descriptor("anthropic", "claude-opus-4-6", config => anthropicModelManagerOptions(config)), - catalogDescriptor( - "alibaba-coding-plan", - "qwen3.5-plus", - config => alibabaCodingPlanModelManagerOptions(config), - catalog("Alibaba Coding Plan", ["ALIBABA_CODING_PLAN_API_KEY"]), - ), - descriptor("openai", "gpt-5.4", config => openaiModelManagerOptions(config)), - descriptor("groq", "openai/gpt-oss-120b", config => groqModelManagerOptions(config)), - catalogDescriptor( - "huggingface", - "deepseek-ai/DeepSeek-R1", - config => huggingfaceModelManagerOptions(config), - catalog("Hugging Face", ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"]), - ), - catalogDescriptor( - "cerebras", - "zai-glm-4.6", - config => cerebrasModelManagerOptions(config), - catalog("Cerebras", ["CEREBRAS_API_KEY"]), - ), - catalogDescriptor( - "fireworks", - "kimi-k2.6", - config => fireworksModelManagerOptions(config), - catalog("Fireworks", ["FIREWORKS_API_KEY"]), - ), - // Fire Pass does not expose a /v1/models endpoint — the API returns HTTP 403 - // on any catalog-discovery request, so dynamic model listing is not feasible. - // - // The single model `kimi-k2.6-turbo` is seeded via the `prevModelsJson` - // fallback in `generate-models.ts`, which preserves entries from the previous - // catalog snapshot when a provider does not surface them dynamically. - // - // IMPORTANT: Do NOT delete the firepass section from models.json. No - // descriptor here produces that entry dynamically — removing it from - // models.json would permanently drop the model from the catalog with no - // automated mechanism to restore it. - descriptor("firepass", "kimi-k2.6-turbo", config => firepassModelManagerOptions(config)), - catalogDescriptor( - "wafer-pass", - "GLM-5.1", - config => waferPassModelManagerOptions(config), - catalog("Wafer Pass", ["WAFER_PASS_API_KEY"], { oauthProvider: "wafer-pass" }), - ), - catalogDescriptor( - "wafer-serverless", - "GLM-5.1", - config => waferServerlessModelManagerOptions(config), - catalog("Wafer Serverless", ["WAFER_SERVERLESS_API_KEY"], { oauthProvider: "wafer-serverless" }), - ), - descriptor("xai", "grok-4-fast-non-reasoning", config => xaiModelManagerOptions(config)), - catalogDescriptor( - "xai-oauth", - "grok-4.3", - config => xaiOAuthModelManagerOptions(config), - catalog("xAI Grok OAuth (SuperGrok)", ["XAI_OAUTH_TOKEN", "XAI_API_KEY"], { - oauthProvider: "xai-oauth", - }), - ), - catalogDescriptor( - "deepseek", - "deepseek-v4-pro", - config => deepseekModelManagerOptions(config), - catalog("DeepSeek", ["DEEPSEEK_API_KEY"]), - ), - descriptor("mistral", "devstral-medium-latest", config => mistralModelManagerOptions(config)), - catalogDescriptor( - "nvidia", - "nvidia/llama-3.1-nemotron-70b-instruct", - config => nvidiaModelManagerOptions(config), - catalog("NVIDIA", ["NVIDIA_API_KEY"]), - ), - catalogDescriptor( - "nanogpt", - "openai/gpt-5.4", - config => nanoGptModelManagerOptions(config), - catalog("NanoGPT", ["NANO_GPT_API_KEY"]), - ), - descriptor("opencode-zen", "claude-sonnet-4-6", config => opencodeZenModelManagerOptions(config)), - descriptor("opencode-go", "kimi-k2.5", config => opencodeGoModelManagerOptions(config)), - catalogDescriptor( - "openrouter", - "openai/gpt-5.4", - config => openrouterModelManagerOptions(config), - catalog("OpenRouter", ["OPENROUTER_API_KEY"], { allowUnauthenticated: true }), - ), - catalogDescriptor( - "kilo", - "anthropic/claude-sonnet-4.5", - config => kiloModelManagerOptions(config), - catalog("Kilo Gateway", ["KILO_API_KEY"], { allowUnauthenticated: true }), - ), - catalogDescriptor( - "vercel-ai-gateway", - "anthropic/claude-sonnet-4-6", - config => vercelAiGatewayModelManagerOptions(config), - catalog("Vercel AI Gateway", ["VERCEL_AI_GATEWAY_API_KEY"], { allowUnauthenticated: true }), - ), - catalogDescriptor( - "ollama", - "gpt-oss:20b", - config => ollamaModelManagerOptions(config), - catalog("Ollama", ["OLLAMA_API_KEY"]), - { allowUnauthenticated: true }, - ), - catalogDescriptor( - "ollama-cloud", - "gpt-oss:120b", - config => ollamaCloudModelManagerOptions(config), - catalog("Ollama Cloud", ["OLLAMA_CLOUD_API_KEY"], { oauthProvider: "ollama-cloud" }), - ), - catalogDescriptor( - "cloudflare-ai-gateway", - "claude-sonnet-4-5", - config => cloudflareAiGatewayModelManagerOptions(config), - catalog("Cloudflare AI Gateway", ["CLOUDFLARE_AI_GATEWAY_API_KEY"]), - ), - catalogDescriptor( - "kimi-code", - "kimi-k2.5", - config => kimiCodeModelManagerOptions(config), - catalog("Kimi Code", ["KIMI_API_KEY"]), - ), - catalogDescriptor( - "qwen-portal", - "coder-model", - config => qwenPortalModelManagerOptions(config), - catalog("Qwen Portal", ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"], { oauthProvider: "qwen-portal" }), - ), - catalogDescriptor( - "synthetic", - "hf:zai-org/GLM-5.1", - config => syntheticModelManagerOptions(config), - catalog("Synthetic", ["SYNTHETIC_API_KEY"]), - { dynamicModelsAuthoritative: true }, - ), - catalogDescriptor( - "venice", - "llama-3.3-70b", - config => veniceModelManagerOptions(config), - catalog("Venice", ["VENICE_API_KEY"], { allowUnauthenticated: true }), - ), - catalogDescriptor( - "litellm", - "claude-opus-4-6", - config => litellmModelManagerOptions(config), - catalog("LiteLLM", ["LITELLM_API_KEY"], { allowUnauthenticated: true }), - ), - descriptor("lm-studio", "llama-3-8b", config => lmStudioModelManagerOptions(config), { allowUnauthenticated: true }), - catalogDescriptor( - "vllm", - "gpt-oss-20b", - config => vllmModelManagerOptions(config), - catalog("vLLM", ["VLLM_API_KEY"], { allowUnauthenticated: true }), - ), - catalogDescriptor( - "moonshot", - "kimi-k2.5", - config => moonshotModelManagerOptions(config), - catalog("Moonshot", ["MOONSHOT_API_KEY"]), - ), - catalogDescriptor( - "qianfan", - "deepseek-v3.2", - config => qianfanModelManagerOptions(config), - catalog("Qianfan", ["QIANFAN_API_KEY"]), - ), - catalogDescriptor( - "together", - "moonshotai/Kimi-K2.5", - config => togetherModelManagerOptions(config), - catalog("Together", ["TOGETHER_API_KEY"]), - ), - catalogDescriptor( - "xiaomi", - "mimo-v2-flash", - config => xiaomiModelManagerOptions(config), - catalog("Xiaomi", ["XIAOMI_API_KEY"]), - ), - descriptor("xiaomi-token-plan-sgp", "mimo-v2.5", config => - xiaomiModelManagerOptions({ ...config, providerId: "xiaomi-token-plan-sgp", tokenPlanRegion: "sgp" }), - ), - descriptor("xiaomi-token-plan-ams", "mimo-v2.5", config => - xiaomiModelManagerOptions({ ...config, providerId: "xiaomi-token-plan-ams", tokenPlanRegion: "ams" }), - ), - descriptor("xiaomi-token-plan-cn", "mimo-v2.5", config => - xiaomiModelManagerOptions({ ...config, providerId: "xiaomi-token-plan-cn", tokenPlanRegion: "cn" }), - ), - catalogDescriptor( - "zenmux", - "anthropic/claude-opus-4.6", - config => zenmuxModelManagerOptions(config), - catalog("ZenMux", ["ZENMUX_API_KEY"]), - ), - catalogDescriptor("zai", "glm-5.1", config => zaiModelManagerOptions(config), catalog("zAI", ["ZAI_API_KEY"])), - catalogDescriptor( - "zhipu-coding-plan", - "glm-5.1", - config => zhipuCodingPlanModelManagerOptions(config), - catalog("Zhipu Coding Plan", ["ZHIPU_API_KEY"]), - ), - descriptor("github-copilot", "gpt-4o", config => githubCopilotModelManagerOptions(config)), - descriptor("google", "gemini-2.5-pro", config => googleModelManagerOptions(config)), - descriptor("google-vertex", "gemini-3-pro-preview", config => googleVertexModelManagerOptions(config), { - allowUnauthenticated: true, - }), - catalogDescriptor( - "cursor", - "claude-sonnet-4-6", - config => cursorModelManagerOptions(config), - catalog("Cursor", ["CURSOR_API_KEY"], { oauthProvider: "cursor" }), - ), -] as const; +export const PROVIDER_DESCRIPTORS: readonly ProviderDescriptor[] = PROVIDER_REGISTRY.flatMap(provider => { + const { createModelManagerOptions } = provider; + if (!createModelManagerOptions || provider.specialModelManager) { + return []; + } + return [ + { + providerId: provider.id, + defaultModel: provider.defaultModel ?? "", + createModelManagerOptions, + allowUnauthenticated: provider.allowUnauthenticated, + dynamicModelsAuthoritative: provider.dynamicModelsAuthoritative, + catalogDiscovery: provider.catalogDiscovery, + }, + ]; +}); -/** Default model IDs for all known providers, built from descriptors + special providers. */ -export const DEFAULT_MODEL_PER_PROVIDER: Record = { - ...Object.fromEntries(PROVIDER_DESCRIPTORS.map(d => [d.providerId, d.defaultModel])), - // Providers not in PROVIDER_DESCRIPTORS (special auth or no standard discovery) - "alibaba-coding-plan": "qwen3.5-plus", - "amazon-bedrock": "us.anthropic.claude-opus-4-6-v1", - "google-antigravity": "gemini-3-pro-high", - "google-gemini-cli": "gemini-2.5-pro", - "google-vertex": "gemini-3-pro-preview", - minimax: "MiniMax-M2.5", - "minimax-code": "MiniMax-M2.5", - "minimax-code-cn": "MiniMax-M2.5", - "openai-codex": "gpt-5.4", - "gitlab-duo": "duo-chat-sonnet-4-5", -} as Record; +/** Default model IDs for all known providers, derived from the registry. */ +export const DEFAULT_MODEL_PER_PROVIDER: Record = Object.fromEntries( + PROVIDER_REGISTRY.filter(provider => provider.defaultModel != null).map( + provider => [provider.id, provider.defaultModel] as [string, string], + ), +) as Record; diff --git a/packages/ai/src/provider-models/discovery-constants.ts b/packages/ai/src/provider-models/discovery-constants.ts new file mode 100644 index 000000000..83d60ffba --- /dev/null +++ b/packages/ai/src/provider-models/discovery-constants.ts @@ -0,0 +1,11 @@ +/** + * Fallback context-window / max-output-token values for models discovered + * without limit metadata. + * + * Kept in a dependency-free leaf module (rather than `openai-compat.ts`) so the + * model-discovery helpers in `utils/discovery/*` can import them without pulling + * the package root barrel (`@oh-my-pi/pi-ai`) into the model-manager init graph, + * which would otherwise form an import cycle through the provider registry. + */ +export const UNK_CONTEXT_WINDOW = 222_222; +export const UNK_MAX_TOKENS = 8_888; diff --git a/packages/ai/src/provider-models/openai-compat.ts b/packages/ai/src/provider-models/openai-compat.ts index c529fb135..ef2aa117c 100644 --- a/packages/ai/src/provider-models/openai-compat.ts +++ b/packages/ai/src/provider-models/openai-compat.ts @@ -1,6 +1,7 @@ import { Effort } from "../effort"; import type { ModelManagerOptions } from "../model-manager"; import { getBundledModels } from "../models"; +import { getGitHubCopilotBaseUrl, OPENCODE_HEADERS, parseGitHubCopilotApiKey } from "../registry/oauth/github-copilot"; import type { Api, Model, Provider, ThinkingConfig } from "../types"; import { isAnthropicOAuthToken, isRecord, toBoolean, toNumber, toPositiveNumber } from "../utils"; import { @@ -9,8 +10,8 @@ import { type OpenAICompatibleModelRecord, } from "../utils/discovery/openai-compatible"; import { toFireworksPublicModelId } from "../utils/fireworks-model-id"; -import { getGitHubCopilotBaseUrl, OPENCODE_HEADERS, parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot"; import { createBundledReferenceMap, createReferenceResolver } from "./bundled-references"; +import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "./discovery-constants"; const MODELS_DEV_URL = "https://models.dev/api.json"; const ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1"; @@ -441,7 +442,7 @@ function isLikelyNanoGptTextModelId(id: string): boolean { type SimpleProviderConfig = { apiKey?: string; baseUrl?: string }; -function createSimpleOpenAICompletionsOptions( +export function createSimpleOpenAICompletionsOptions( providerId: Parameters[0], defaultBaseUrl: string, config?: SimpleProviderConfig, @@ -1916,12 +1917,23 @@ export function moonshotModelManagerOptions( const reference = references.get(defaults.id); const model = mapWithBundledReference(entry, defaults, reference); const id = model.id.toLowerCase(); - const isThinking = id.includes("thinking"); - const isVision = id.includes("vision") || id.includes("vl") || id.includes("k2.5"); + // Moonshot's K2.x family (K2.5, K2.6, kimi-k2-thinking, …) is reasoning-capable + // and vision-capable on the native API. Without these flags the openai-completions + // path skips the z.ai-format `thinking` block, and Moonshot K2.6 stalls on first + // turn because its endpoint expects an explicit `thinking: {type}` (#2113). Match + // the bundled K2.5 metadata for every K2.x id we discover. + const isKimiK2Reasoning = id.includes("thinking") || /(^|\/)kimi-k2(?:\.\d+)?(?:[-:]|$)/.test(id); + const isVision = + id.includes("vision") || id.includes("vl") || /(^|\/)kimi-k2(?:\.\d+)?(?:[-:]|$)/.test(id); return { ...model, - reasoning: isThinking || model.reasoning, + reasoning: isKimiK2Reasoning || model.reasoning, input: isVision ? ["text", "image"] : model.input, + thinking: + model.thinking ?? + (isKimiK2Reasoning + ? { mode: "effort", minLevel: Effort.Minimal, maxLevel: Effort.High } + : undefined), }; }, }), @@ -2385,8 +2397,7 @@ export function anthropicModelManagerOptions( // Models.dev provider descriptors for generate-models.ts // --------------------------------------------------------------------------- -export const UNK_CONTEXT_WINDOW = 222_222; -export const UNK_MAX_TOKENS = 8_888; +export { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "./discovery-constants"; /** Describes how to map models.dev API data for a single provider. */ export interface ModelsDevProviderDescriptor { diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 80331dc09..12adf6df0 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -19,6 +19,7 @@ import { } from "../model-thinking"; import { calculateCost } from "../models"; import { isUsageLimitError } from "../rate-limit-utils"; +import { parseGitHubCopilotApiKey } from "../registry/oauth/github-copilot"; import { getEnvApiKey, OUTPUT_FALLBACK_BUFFER } from "../stream"; import type { Api, @@ -57,8 +58,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream"; import { isFoundryEnabled } from "../utils/foundry"; import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector"; import { getStreamFirstEventTimeoutMs, getStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator"; -import { parseJsonWithRepair, parseStreamingJson, parseStreamingJsonThrottled } from "../utils/json-parse"; -import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot"; +import { parseStreamingJsonThrottled } from "../utils/json-parse"; import { notifyProviderResponse } from "../utils/provider-response"; import { isCopilotTransientModelError } from "../utils/retry"; import { COMBINATOR_KEYS, NO_STRICT, toolWireSchema } from "../utils/schema"; @@ -257,6 +257,25 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record; +type AnthropicImageMediaType = "image/jpeg" | "image/png" | "image/gif" | "image/webp"; + +function normalizeAnthropicImageMediaType(mimeType: string): AnthropicImageMediaType | undefined { + const normalized = mimeType.trim().toLowerCase(); + if (normalized === "image/jpg") return "image/jpeg"; + if ( + normalized === "image/jpeg" || + normalized === "image/png" || + normalized === "image/gif" || + normalized === "image/webp" + ) { + return normalized; + } + return undefined; +} + +function cloneAnthropicCacheControl(cacheControl: AnthropicCacheControl): AnthropicCacheControl { + return { ...cacheControl }; +} type AnthropicOutputConfig = NonNullable; @@ -294,16 +313,29 @@ function createAnthropicProviderSessionState(): AnthropicProviderSessionState { return state; } +/** + * Key the sticky strict-tools / fast-mode learning per endpoint+model. A + * grammar-too-large 400 or a fast-mode rejection is specific to the model (its + * tool grammar / entitlement) and the endpoint (direct Anthropic vs a gateway / + * Foundry / Bedrock proxy), so it MUST NOT bleed onto unrelated anthropic-messages + * requests in the same session. NUL separates the two components so neither can + * forge the boundary. + */ +function anthropicProviderSessionStateKey(baseUrl: string, modelId: string): string { + return `${ANTHROPIC_PROVIDER_SESSION_STATE_KEY}:${baseUrl}\u0000${modelId}`; +} + function getAnthropicProviderSessionState( providerSessionState: Map | undefined, + baseUrl: string, + modelId: string, ): AnthropicProviderSessionState | undefined { if (!providerSessionState) return undefined; - const existing = providerSessionState.get(ANTHROPIC_PROVIDER_SESSION_STATE_KEY) as - | AnthropicProviderSessionState - | undefined; + const key = anthropicProviderSessionStateKey(baseUrl, modelId); + const existing = providerSessionState.get(key) as AnthropicProviderSessionState | undefined; if (existing) return existing; const created = createAnthropicProviderSessionState(); - providerSessionState.set(ANTHROPIC_PROVIDER_SESSION_STATE_KEY, created); + providerSessionState.set(key, created); return created; } @@ -318,10 +350,14 @@ export function clearAnthropicFastModeFallback( providerSessionState: Map | undefined, ): void { if (!providerSessionState) return; - const state = providerSessionState.get(ANTHROPIC_PROVIDER_SESSION_STATE_KEY) as - | AnthropicProviderSessionState - | undefined; - if (state) state.fastModeDisabled = false; + // Fast mode is re-armed session-wide (user toggled `/fast on`), so clear the + // sticky flag on every per-endpoint/model Anthropic entry — plus the legacy + // unscoped key — rather than a single shared object. + const prefix = `${ANTHROPIC_PROVIDER_SESSION_STATE_KEY}:`; + for (const [key, value] of providerSessionState) { + if (key !== ANTHROPIC_PROVIDER_SESSION_STATE_KEY && !key.startsWith(prefix)) continue; + (value as AnthropicProviderSessionState).fastModeDisabled = false; + } } function isAnthropicStrictGrammarTooLargeError(error: unknown): boolean { @@ -499,37 +535,28 @@ const CCH_PLACEHOLDER = cchEncoder.encode(CCH_PLACEHOLDER_STR); const BILLING_SYSTEM_MARKER = cchEncoder.encode(`"system":[{"type":"text","text":"${CLAUDE_BILLING_HEADER_PREFIX}`); const CCH_BILLING_SEARCH_WINDOW = 150; -function patchCch(body: Uint8Array): Uint8Array { - // Find the combined system[0] + billing-header prefix marker. - let markerIdx = -1; - outer: for (let i = 0; i <= body.length - BILLING_SYSTEM_MARKER.length; i++) { - for (let j = 0; j < BILLING_SYSTEM_MARKER.length; j++) { - if (body[i + j] !== BILLING_SYSTEM_MARKER[j]) continue outer; - } - markerIdx = i; - break; - } - if (markerIdx === -1) return body; // no CC billing header injected +function patchCch(body: Uint8Array): boolean { + // Zero-copy Buffer view over the same memory; its `indexOf` is a native memmem, + // ~7.5x faster than a hand-rolled byte loop here — the marker sits ~99% through + // the body because `messages` serializes before `system`, so a JS scan would + // walk almost the entire payload (benchmarked: 563µs -> 75µs on a 1MB body). + const view = Buffer.from(body.buffer, body.byteOffset, body.byteLength); - // Scan at most CCH_BILLING_SEARCH_WINDOW bytes after the marker for the placeholder. + // Find the combined system[0] + billing-header prefix marker. + const markerIdx = view.indexOf(BILLING_SYSTEM_MARKER); + if (markerIdx === -1) return false; // no CC billing header injected + + // Placeholder must sit within CCH_BILLING_SEARCH_WINDOW bytes after the marker. const searchFrom = markerIdx + BILLING_SYSTEM_MARKER.length; - const searchTo = Math.min(searchFrom + CCH_BILLING_SEARCH_WINDOW, body.length - CCH_PLACEHOLDER.length); - let idx = -1; - outer2: for (let i = searchFrom; i <= searchTo; i++) { - for (let j = 0; j < CCH_PLACEHOLDER.length; j++) { - if (body[i + j] !== CCH_PLACEHOLDER[j]) continue outer2; - } - idx = i; - break; - } - if (idx === -1) return body; // placeholder not within the billing header value + const idx = view.indexOf(CCH_PLACEHOLDER, searchFrom); + if (idx === -1 || idx - searchFrom > CCH_BILLING_SEARCH_WINDOW) return false; // Hash the body with the placeholder in place (matches CC's in-place behaviour). const h = Bun.hash.xxHash64(body, CCH_SEED); const cch = (h & 0xfffffn).toString(16).padStart(5, "0"); for (let i = 0; i < 5; i++) body[idx + 4 + i] = cch.charCodeAt(i); - return body; + return true; } type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise; @@ -538,8 +565,15 @@ function wrapFetchForCch(base: FetchFn): FetchFn { return (input, init) => { if (init?.body && typeof init.body === "string" && init.body.includes(CCH_PLACEHOLDER_STR)) { const encoded = cchEncoder.encode(init.body); - const patched = patchCch(encoded); - return base(input, { ...init, body: patched }); + if (!patchCch(encoded)) { + // The OAuth billing placeholder is present but we couldn't anchor it to + // system[0] — e.g. an `onPayload` hook reordered the first system block's keys + // so BILLING_SYSTEM_MARKER no longer matches. Send the body as-is (cch stays + // `00000`, the prior behaviour) rather than failing the request, but surface the + // fingerprint regression instead of letting it ship silently. + logger.warn("anthropic: cch billing placeholder present but not patched; sending unattested request"); + } + return base(input, { ...init, body: encoded }); } return base(input, init); }; @@ -596,20 +630,54 @@ export function generateClaudeCloakingUserId(): string { return `user_${userHash}_account_${accountId}_session_${sessionId}`; } -function deriveClaudeDeviceIdFromInstallId(): string { - return nodeCrypto.createHash("sha256").update(`omp-claude-device-id-v1:${getInstallId()}`).digest("hex"); +const CLAUDE_DEVICE_ID_INSTALL_HASH_DOMAIN = "omp-claude-device-id-v1:"; +const CLAUDE_DEVICE_ID_ACCOUNT_HASH_DOMAIN = "omp-claude-device-id-v2"; + +export function deriveClaudeDeviceId(installId: string, accountId?: string): string { + const hash = nodeCrypto.createHash("sha256"); + if (accountId && accountId.length > 0) { + return hash + .update(CLAUDE_DEVICE_ID_ACCOUNT_HASH_DOMAIN) + .update("\0") + .update(installId) + .update("\0") + .update(accountId) + .digest("hex"); + } + return hash.update(CLAUDE_DEVICE_ID_INSTALL_HASH_DOMAIN).update(installId).digest("hex"); } -function generateClaudeJsonUserId(sessionId?: string): string { - return JSON.stringify({ - device_id: deriveClaudeDeviceIdFromInstallId(), + +function readMetadataString(metadata: Record | undefined, key: string): string | undefined { + const value = metadata?.[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function readAnthropicMetadataAccountId(metadata: Record | undefined): string | undefined { + return ( + readMetadataString(metadata, "account_uuid") ?? + readMetadataString(metadata, "accountId") ?? + readMetadataString(metadata, "account_id") + ); +} + +function deriveClaudeDeviceIdFromInstallId(accountId?: string): string { + return deriveClaudeDeviceId(getInstallId(), accountId); +} + +function generateClaudeJsonUserId(sessionId?: string, accountId?: string): string { + const userId: Record = { + device_id: deriveClaudeDeviceIdFromInstallId(accountId), session_id: sessionId ?? nodeCrypto.randomUUID().toLowerCase(), - }); + }; + if (accountId && accountId.length > 0) userId.account_uuid = accountId; + return JSON.stringify(userId); } function resolveAnthropicMetadataUserId( userId: unknown, isOAuthToken: boolean, sessionId?: string, + accountId?: string, ): string | undefined { if (typeof userId === "string") { if (!isOAuthToken || isClaudeCloakingUserId(userId) || isClaudeJsonUserId(userId)) { @@ -618,13 +686,17 @@ function resolveAnthropicMetadataUserId( } if (!isOAuthToken) return undefined; - return generateClaudeJsonUserId(sessionId); + return generateClaudeJsonUserId(sessionId, accountId); } const ANTHROPIC_BUILTIN_TOOL_NAMES = new Set(["web_search", "code_execution", "text_editor", "computer"]); export const applyClaudeToolPrefix = (name: string): string => { if (!claudeToolPrefix) return name; if (ANTHROPIC_BUILTIN_TOOL_NAMES.has(name.toLowerCase())) return name; - if (name.toLowerCase().startsWith(claudeToolPrefix.toLowerCase())) return name; + // Always prepend (no "already prefixed" short-circuit): the prefix is a wire + // transport detail applied once to internal tool names, and `stripClaudeToolPrefix` + // removes exactly one prefix on receive. Skipping names that already start with the + // prefix would make a tool literally named `_foo` lose its leading underscore on the + // return trip (`_foo` → wire `_foo` → strip → `foo`), so the agent loop can't find it. return `${claudeToolPrefix}${name}`; }; @@ -750,42 +822,67 @@ function convertContentBlocks( type: "image"; source: { type: "base64"; - media_type: "image/jpeg" | "image/png" | "image/gif" | "image/webp"; + media_type: AnthropicImageMediaType; data: string; }; } > { - const textBlocks = content - .filter((block): block is TextContent => block.type === "text") - .map(block => block.text.toWellFormed()) - .filter(text => text.trim().length > 0); - const imageBlocks = content.filter((block): block is ImageContent => block.type === "image"); - const omittedImages = !supportsImages && imageBlocks.length > 0; - if (imageBlocks.length === 0 || !supportsImages) { - if (omittedImages) { - textBlocks.push(NON_VISION_IMAGE_PLACEHOLDER); - } - return textBlocks.join("\n").toWellFormed(); - } + const blocks: Array< + | { type: "text"; text: string } + | { + type: "image"; + source: { + type: "base64"; + media_type: AnthropicImageMediaType; + data: string; + }; + } + > = []; + let sawText = false; + let sawImage = false; - const blocks = [ - ...textBlocks.map(text => ({ - type: "text" as const, - text, - })), - ...imageBlocks.map(block => ({ - type: "image" as const, + for (const block of content) { + if (block.type === "text") { + const text = block.text.toWellFormed(); + if (text.trim().length === 0) continue; + sawText = true; + blocks.push({ type: "text", text }); + continue; + } + + if (!supportsImages) { + blocks.push({ type: "text", text: NON_VISION_IMAGE_PLACEHOLDER }); + continue; + } + + const mediaType = normalizeAnthropicImageMediaType(block.mimeType); + if (!mediaType) { + blocks.push({ type: "text", text: `[unsupported image: ${block.mimeType}]` }); + continue; + } + + sawImage = true; + blocks.push({ + type: "image", source: { - type: "base64" as const, - media_type: block.mimeType as "image/jpeg" | "image/png" | "image/gif" | "image/webp", + type: "base64", + media_type: mediaType, data: block.data, }, - })), - ]; + }); + } - if (!textBlocks.length) { + if (!supportsImages) { + return blocks + .filter((block): block is { type: "text"; text: string } => block.type === "text") + .map(block => block.text) + .join("\n") + .toWellFormed(); + } + + if (sawImage && !sawText) { blocks.unshift({ - type: "text" as const, + type: "text", text: "(see attached image)", }); } @@ -887,6 +984,16 @@ type FoundryTlsOptions = { key?: string; }; +const foundryTlsOptionsCache = new Map(); + +function foundryTlsOptionsCacheKey(): string { + return JSON.stringify([ + $env.NODE_EXTRA_CA_CERTS ?? null, + $env.CLAUDE_CODE_CLIENT_CERT ?? null, + $env.CLAUDE_CODE_CLIENT_KEY ?? null, + ]); +} + function resolveAnthropicBaseUrl(model: Model<"anthropic-messages">, apiKey?: string): string | undefined { if (model.provider === "github-copilot") { return normalizeAnthropicBaseUrl(resolveGitHubCopilotBaseUrl(model.baseUrl, apiKey) ?? model.baseUrl); @@ -975,6 +1082,9 @@ function resolveFoundryTlsOptions(model: Model<"anthropic-messages">): FoundryTl if (model.provider !== "anthropic") return undefined; if (!isFoundryEnabled()) return undefined; + const cacheKey = foundryTlsOptionsCacheKey(); + if (foundryTlsOptionsCache.has(cacheKey)) return foundryTlsOptionsCache.get(cacheKey); + const ca = resolvePemValue($env.NODE_EXTRA_CA_CERTS, "NODE_EXTRA_CA_CERTS"); const cert = resolvePemValue($env.CLAUDE_CODE_CLIENT_CERT, "CLAUDE_CODE_CLIENT_CERT"); const key = resolvePemValue($env.CLAUDE_CODE_CLIENT_KEY, "CLAUDE_CODE_CLIENT_KEY"); @@ -987,7 +1097,9 @@ function resolveFoundryTlsOptions(model: Model<"anthropic-messages">): FoundryTl if (ca) options.ca = [...tls.rootCertificates, ca]; if (cert) options.cert = cert; if (key) options.key = key; - return Object.keys(options).length > 0 ? options : undefined; + const resolved = Object.keys(options).length > 0 ? options : undefined; + foundryTlsOptionsCache.set(cacheKey, resolved); + return resolved; } function buildClaudeCodeTlsFetchOptions( @@ -1037,14 +1149,9 @@ const ANTHROPIC_MESSAGE_EVENTS: ReadonlySet = new Set([ ]); /** - * Anthropic keepalive `ping` events carry no message content, but they prove the - * upstream connection is alive during long server-side gaps (extended thinking, - * slow tool execution). They are normally dropped before reaching the consumer; - * we instead surface them as lightweight markers so the idle watchdog - * (`iterateWithIdleTimeout`) resets its deadline on every ping. Without this, a - * connection that is demonstrably still streaming pings still trips - * "Anthropic stream stalled while waiting for the next event". The message-event - * branches in `streamAnthropic` match none of these markers, so they are ignored. + * Iterate over Anthropic SSE events from a raw Response, preserving ping events + * for liveness. Malformed event envelopes are logged and skipped (non-fatal) + * rather than aborting the stream. */ type RawMessagePingEvent = { type: "ping" }; type AnthropicStreamEvent = RawMessageStreamEvent | RawMessagePingEvent; @@ -1079,7 +1186,10 @@ async function* iterateAnthropicEvents( } try { - const event = parseJsonWithRepair(sse.data); + const event = JSON.parse(sse.data) as RawMessageStreamEvent; + if (event.type !== sse.event) { + reportAnthropicEnvelopeAnomaly(`event type ${event.type} does not match SSE event ${sse.event}`); + } if (event.type === "message_start") { sawMessageStart = true; } else if (event.type === "message_stop") { @@ -1088,14 +1198,14 @@ async function* iterateAnthropicEvents( yield event; } catch (error) { const message = error instanceof Error ? error.message : String(error); - throw new Error( - `Could not parse Anthropic SSE event ${sse.event}: ${message}; data=${sse.data}; raw=${sse.raw.join("\\n")}`, + reportAnthropicEnvelopeAnomaly( + `could not parse SSE event ${sse.event}: ${message}; skipping frame; data=${sse.data}`, ); } } - if (sawMessageStart && !sawMessageEnd) { - throw createAnthropicStreamEnvelopeError("stream ended before message_stop"); + if (sawMessageStart && !sawMessageEnd && !signal?.aborted) { + reportAnthropicEnvelopeAnomaly("stream ended before message_stop"); } } @@ -1177,17 +1287,12 @@ function getAnthropicCompat( const PROVIDER_MAX_RETRIES = 3; const PROVIDER_BASE_DELAY_MS = 2000; -/** - * Check if an error from the Anthropic SDK is a rate-limit/transient error that - * should be retried before any content has been emitted. - * - * Includes malformed JSON stream-envelope parse errors seen from some - * Anthropic-compatible proxy endpoints. - */ /** Transient stream corruption errors where the response was truncated mid-JSON. */ function isTransientStreamParseError(error: unknown): boolean { if (!(error instanceof Error)) return false; - return /json parse error|unterminated string|unexpected end of json input/i.test(error.message); + return /unterminated string|unexpected end of json input|unexpected end of data|unexpected eof|end of file|eof while parsing|truncated/i.test( + error.message, + ); } const ANTHROPIC_STREAM_ENVELOPE_ERROR_PREFIX = "Anthropic stream envelope error:"; @@ -1196,6 +1301,17 @@ function createAnthropicStreamEnvelopeError(message: string): Error { return new Error(`${ANTHROPIC_STREAM_ENVELOPE_ERROR_PREFIX} ${message}`); } +/** + * Log a malformed-stream-envelope anomaly without aborting the turn. The strict + * parser would `throw createAnthropicStreamEnvelopeError(...)` here; we instead + * surface a warning and let the caller skip the offending event (or finalize what + * already streamed) so a non-conforming endpoint degrades to best-effort content + * rather than failing the request. + */ +function reportAnthropicEnvelopeAnomaly(detail: string): void { + logger.warn(`anthropic: ignoring malformed stream envelope: ${detail}`); +} + const ANTHROPIC_PRE_MESSAGE_START_EVENT_TYPES = new Set([ "content_block_start", "content_block_delta", @@ -1235,6 +1351,8 @@ export function isProviderRetryableError(error: unknown, provider?: string): boo // `streamSimple` a/b/c policy), so surface them immediately instead of // burning the retry budget here. if (isUsageLimitError(error.message)) return false; + const status = extractHttpStatusFromError(error); + if (status !== undefined && status >= 400 && status < 500 && status !== 408 && status !== 429) return false; const msg = error.message.toLowerCase(); if ( isUnexpectedSocketCloseMessage(msg) || @@ -1268,13 +1386,12 @@ export type AnthropicUsageLike = { /** * Capture Anthropic's optional cache-creation TTL breakdown and server-tool-use - * counters into the harness Usage shape. Only sets fields that were reported, so - * a `message_delta` that omits `cache_creation` does not clobber the breakdown - * established at `message_start`. + * counters into the harness Usage shape. Omitted/null fields are no-ops; explicit + * zero-valued objects clear prior extras from earlier stream usage snapshots. */ export function applyAnthropicUsageExtras(usage: Usage, source: AnthropicUsageLike): void { const cacheCreation = source.cache_creation; - if (cacheCreation) { + if (cacheCreation != null) { const fiveMinute = cacheCreation.ephemeral_5m_input_tokens ?? 0; const oneHour = cacheCreation.ephemeral_1h_input_tokens ?? 0; if (fiveMinute > 0 || oneHour > 0) { @@ -1282,10 +1399,12 @@ export function applyAnthropicUsageExtras(usage: Usage, source: AnthropicUsageLi ...(fiveMinute > 0 ? { ephemeral5m: fiveMinute } : {}), ...(oneHour > 0 ? { ephemeral1h: oneHour } : {}), }; + } else { + delete usage.cttl; } } const serverToolUse = source.server_tool_use; - if (serverToolUse) { + if (serverToolUse != null) { const webSearch = serverToolUse.web_search_requests ?? 0; const webFetch = serverToolUse.web_fetch_requests ?? 0; if (webSearch > 0 || webFetch > 0) { @@ -1293,6 +1412,8 @@ export function applyAnthropicUsageExtras(usage: Usage, source: AnthropicUsageLi ...(webSearch > 0 ? { webSearch } : {}), ...(webFetch > 0 ? { webFetch } : {}), }; + } else { + delete usage.server; } } } @@ -1377,7 +1498,11 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( const baseUrl = resolveAnthropicBaseUrl(model, options?.apiKey ?? getEnvApiKey(model.provider) ?? "") ?? "https://api.anthropic.com"; - const providerSessionState = getAnthropicProviderSessionState(options?.providerSessionState); + const providerSessionState = getAnthropicProviderSessionState( + options?.providerSessionState, + baseUrl, + model.id, + ); let disableStrictTools = (providerSessionState?.strictToolsDisabled ?? false) || (model.compat?.disableStrictTools ?? false); let strictFallbackErrorMessage: string | undefined; @@ -1418,6 +1543,30 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( const requestTimeoutMs = firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0 ? firstEventTimeoutMs : undefined; const blocks = output.content as Block[]; + const finalizeStreamBlock = (block: Block, contentIndex: number): void => { + delete (block as { index?: number }).index; + if (block.type === "text") { + stream.push({ type: "text_end", contentIndex, content: block.text, partial: output }); + } else if (block.type === "thinking") { + stream.push({ type: "thinking_end", contentIndex, content: block.thinking, partial: output }); + } else if (block.type === "toolCall") { + const finalJson = + block.partialJson.length > 0 ? block.partialJson : JSON.stringify(block.arguments ?? {}); + try { + block.arguments = JSON.parse(finalJson) as ToolCall["arguments"]; + } catch (parseError) { + // Non-fatal: keep the best-effort arguments recovered by the throttled streaming + // parser instead of failing the turn on malformed/truncated tool-argument JSON. + reportAnthropicEnvelopeAnomaly( + `tool_use ${block.id} arguments are not valid JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`, + ); + block.arguments = (block.arguments ?? {}) as ToolCall["arguments"]; + } + delete (block as { partialJson?: string }).partialJson; + delete (block as { lastParseLen?: number }).lastParseLen; + stream.push({ type: "toolcall_end", contentIndex, toolCall: block, partial: output }); + } + }; stream.push({ type: "start", partial: output }); // Retry loop for transient errors from the stream. // Provider-level transport/rate-limit failures: only before any streamed content starts. @@ -1466,6 +1615,11 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( let sawEvent = false; let sawMessageStart = false; let sawTerminalEnvelope = false; + let sawMessageStop = false; + const openBlocks = new Map< + number, + { contentIndex: number; kind: "text" | "thinking" | "redactedThinking" | "toolCall" | "ignored" } + >(); const timedAnthropicStream = iterateWithIdleTimeout(anthropicStream, { idleTimeoutMs, @@ -1508,6 +1662,14 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( } if (event.type === "content_block_start") { + if (sawTerminalEnvelope) { + reportAnthropicEnvelopeAnomaly(`received ${event.type} after terminal stop signal`); + continue; + } + if (openBlocks.has(event.index)) { + reportAnthropicEnvelopeAnomaly(`duplicate content_block_start index ${event.index}`); + continue; + } if (!firstTokenTime) firstTokenTime = Date.now(); if (event.content_block.type === "text") { streamedReplayUnsafeContent = true; @@ -1517,12 +1679,15 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( index: event.index, }; output.content.push(block); + const contentIndex = output.content.length - 1; + openBlocks.set(event.index, { contentIndex, kind: "text" }); stream.push({ type: "text_start", - contentIndex: output.content.length - 1, + contentIndex, partial: output, }); } else if (event.content_block.type === "thinking") { + streamedReplayUnsafeContent = true; const block: Block = { type: "thinking", thinking: "", @@ -1530,18 +1695,25 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( index: event.index, }; output.content.push(block); + const contentIndex = output.content.length - 1; + openBlocks.set(event.index, { contentIndex, kind: "thinking" }); stream.push({ type: "thinking_start", - contentIndex: output.content.length - 1, + contentIndex, partial: output, }); } else if (event.content_block.type === "redacted_thinking") { + streamedReplayUnsafeContent = true; const block: Block = { type: "redactedThinking", data: event.content_block.data, index: event.index, }; output.content.push(block); + openBlocks.set(event.index, { + contentIndex: output.content.length - 1, + kind: "redactedThinking", + }); } else if (event.content_block.type === "tool_use") { streamedReplayUnsafeContent = true; const block: Block = { @@ -1555,93 +1727,105 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( index: event.index, }; output.content.push(block); + const contentIndex = output.content.length - 1; + openBlocks.set(event.index, { contentIndex, kind: "toolCall" }); stream.push({ type: "toolcall_start", - contentIndex: output.content.length - 1, + contentIndex, partial: output, }); + } else { + openBlocks.set(event.index, { contentIndex: -1, kind: "ignored" }); } } else if (event.type === "content_block_delta") { + if (sawTerminalEnvelope) { + reportAnthropicEnvelopeAnomaly(`received ${event.type} after terminal stop signal`); + continue; + } + const openBlock = openBlocks.get(event.index); + if (!openBlock) { + reportAnthropicEnvelopeAnomaly( + `received content_block_delta for unopened index ${event.index}`, + ); + continue; + } + if (openBlock.kind === "ignored") continue; + const block = blocks[openBlock.contentIndex]; if (event.delta.type === "text_delta") { - const index = blocks.findIndex(b => b.index === event.index); - const block = blocks[index]; - if (block && block.type === "text") { - block.text += event.delta.text; - stream.push({ - type: "text_delta", - contentIndex: index, - delta: event.delta.text, - partial: output, - }); + if (openBlock.kind !== "text" || block?.type !== "text") { + reportAnthropicEnvelopeAnomaly(`received text_delta for ${openBlock.kind} block`); + continue; } + streamedReplayUnsafeContent = true; + block.text += event.delta.text; + stream.push({ + type: "text_delta", + contentIndex: openBlock.contentIndex, + delta: event.delta.text, + partial: output, + }); } else if (event.delta.type === "thinking_delta") { - const index = blocks.findIndex(b => b.index === event.index); - const block = blocks[index]; - if (block && block.type === "thinking") { - block.thinking += event.delta.thinking; - stream.push({ - type: "thinking_delta", - contentIndex: index, - delta: event.delta.thinking, - partial: output, - }); + if (openBlock.kind !== "thinking" || block?.type !== "thinking") { + reportAnthropicEnvelopeAnomaly(`received thinking_delta for ${openBlock.kind} block`); + continue; } + streamedReplayUnsafeContent = true; + block.thinking += event.delta.thinking; + stream.push({ + type: "thinking_delta", + contentIndex: openBlock.contentIndex, + delta: event.delta.thinking, + partial: output, + }); } else if (event.delta.type === "input_json_delta") { - const index = blocks.findIndex(b => b.index === event.index); - const block = blocks[index]; - if (block && block.type === "toolCall") { - block.partialJson += event.delta.partial_json; - const throttled = parseStreamingJsonThrottled(block.partialJson, block.lastParseLen ?? 0); - if (throttled) { - block.arguments = throttled.value; - block.lastParseLen = throttled.parsedLen; - } - stream.push({ - type: "toolcall_delta", - contentIndex: index, - delta: event.delta.partial_json, - partial: output, - }); + if (openBlock.kind !== "toolCall" || block?.type !== "toolCall") { + reportAnthropicEnvelopeAnomaly(`received input_json_delta for ${openBlock.kind} block`); + continue; } + streamedReplayUnsafeContent = true; + block.partialJson += event.delta.partial_json; + const throttled = parseStreamingJsonThrottled(block.partialJson, block.lastParseLen ?? 0); + if (throttled) { + block.arguments = throttled.value; + block.lastParseLen = throttled.parsedLen; + } + stream.push({ + type: "toolcall_delta", + contentIndex: openBlock.contentIndex, + delta: event.delta.partial_json, + partial: output, + }); } else if (event.delta.type === "signature_delta") { - const index = blocks.findIndex(b => b.index === event.index); - const block = blocks[index]; - if (block && block.type === "thinking") { - block.thinkingSignature = block.thinkingSignature || ""; - block.thinkingSignature += event.delta.signature; + if (openBlock.kind !== "thinking" || block?.type !== "thinking") { + reportAnthropicEnvelopeAnomaly(`received signature_delta for ${openBlock.kind} block`); + continue; } + streamedReplayUnsafeContent = true; + block.thinkingSignature = block.thinkingSignature || ""; + block.thinkingSignature += event.delta.signature; } } else if (event.type === "content_block_stop") { - const index = blocks.findIndex(b => b.index === event.index); - const block = blocks[index]; - if (block) { - delete (block as { index?: number }).index; - if (block.type === "text") { - stream.push({ - type: "text_end", - contentIndex: index, - content: block.text, - partial: output, - }); - } else if (block.type === "thinking") { - stream.push({ - type: "thinking_end", - contentIndex: index, - content: block.thinking, - partial: output, - }); - } else if (block.type === "toolCall") { - block.arguments = parseStreamingJson(block.partialJson); - delete (block as { partialJson?: string }).partialJson; - delete (block as { lastParseLen?: number }).lastParseLen; - stream.push({ - type: "toolcall_end", - contentIndex: index, - toolCall: block, - partial: output, - }); - } + if (sawTerminalEnvelope) { + reportAnthropicEnvelopeAnomaly(`received ${event.type} after terminal stop signal`); + continue; } + const openBlock = openBlocks.get(event.index); + if (!openBlock) { + reportAnthropicEnvelopeAnomaly(`received content_block_stop for unopened index ${event.index}`); + continue; + } + if (openBlock.kind === "ignored") { + openBlocks.delete(event.index); + continue; + } + const block = blocks[openBlock.contentIndex]; + if (!block || block.type !== openBlock.kind) { + reportAnthropicEnvelopeAnomaly(`content_block_stop kind mismatch for index ${event.index}`); + openBlocks.delete(event.index); + continue; + } + openBlocks.delete(event.index); + finalizeStreamBlock(block, openBlock.contentIndex); } else if (event.type === "message_delta") { const rawStopReason = event.delta.stop_reason; if (rawStopReason) { @@ -1683,6 +1867,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( calculateCost(model, output.usage); } else if (event.type === "message_stop") { sawTerminalEnvelope = true; + sawMessageStop = true; } } @@ -1696,8 +1881,19 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( if (!sawEvent || !sawMessageStart) { throw createAnthropicStreamEnvelopeError("stream ended before message_start"); } - if (!sawTerminalEnvelope) { - throw createAnthropicStreamEnvelopeError("stream ended before terminal stop signal"); + if (!sawMessageStop) { + reportAnthropicEnvelopeAnomaly("stream ended before message_stop"); + } + if (openBlocks.size > 0) { + for (const [openIndex, openBlock] of openBlocks) { + reportAnthropicEnvelopeAnomaly( + `stream ended with an unterminated ${openBlock.kind} block at index ${openIndex}`, + ); + if (openBlock.kind === "ignored" || openBlock.contentIndex < 0) continue; + const danglingBlock = blocks[openBlock.contentIndex]; + if (danglingBlock) finalizeStreamBlock(danglingBlock, openBlock.contentIndex); + } + openBlocks.clear(); } if (output.stopReason === "aborted" || output.stopReason === "error") { @@ -1786,7 +1982,6 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( firstTokenTime = undefined; } } - output.duration = Date.now() - startTime; if (firstTokenTime) output.ttft = firstTokenTime - startTime; if (dropFastMode && resolveServiceTier(options?.serviceTier, model.provider) === "priority") { @@ -1832,12 +2027,11 @@ function applyClaudeCodeSystemCache( blocks: AnthropicSystemBlock[], cacheControl: AnthropicCacheControl | undefined, ): number { - if (!cacheControl || blocks.length <= 2) return 0; - blocks[2] = { ...blocks[2], cache_control: cacheControl }; - if (blocks.length === 3) return 1; + if (!cacheControl || blocks.length === 0) return 0; const lastIndex = blocks.length - 1; - blocks[lastIndex] = { ...blocks[lastIndex], cache_control: cacheControl }; - return 2; + if (blocks[lastIndex].cache_control != null) return 0; + blocks[lastIndex] = { ...blocks[lastIndex], cache_control: cloneAnthropicCacheControl(cacheControl) }; + return 1; } export function buildAnthropicSystemBlocks( @@ -1874,8 +2068,8 @@ export function buildAnthropicSystemBlocks( blocks.push({ type: "text", text: prompt }); } const lastIndex = blocks.length - 1; - if (cacheControl && lastIndex >= 0) { - blocks[lastIndex] = { ...blocks[lastIndex], cache_control: cacheControl }; + if (cacheControl && lastIndex >= 0 && blocks[lastIndex].cache_control == null) { + blocks[lastIndex] = { ...blocks[lastIndex], cache_control: cloneAnthropicCacheControl(cacheControl) }; } return blocks.length > 0 ? blocks : undefined; } @@ -1992,7 +2186,6 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A ...(tlsFetchOptions ? { fetchOptions: tlsFetchOptions } : {}), }; } - // OpenCode Zen's Anthropic-compatible gateway accepts bearer auth only; // leaving apiKey set lets the client add X-Api-Key, which upstream Alibaba rejects. if (model.provider === "opencode-zen") { @@ -2008,9 +2201,16 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A }; } + const authorizationHeader = getHeaderCaseInsensitive(defaultHeaders, "Authorization"); + const shouldSuppressClientApiKey = + !oauthToken && + !isAnthropicApiBaseUrl(baseUrl) && + typeof authorizationHeader === "string" && + /^Bearer\s+/i.test(authorizationHeader); + return { isOAuthToken: oauthToken, - apiKey: oauthToken ? null : apiKey, + apiKey: oauthToken || shouldSuppressClientApiKey ? null : apiKey, authToken: oauthToken ? apiKey : undefined, baseURL: baseUrl, maxRetries: 5, @@ -2035,6 +2235,7 @@ function disableThinkingIfToolChoiceForced(params: MessageCreateParamsStreaming) if (toolChoice.type !== "any" && toolChoice.type !== "tool") return; delete params.thinking; + delete params.context_management; const outputConfig = params.output_config as AnthropicOutputConfig | undefined; if (!outputConfig) return; @@ -2051,11 +2252,23 @@ function ensureMaxTokensForThinking(params: MessageCreateParamsStreaming, model: const budgetTokens = thinking.budget_tokens ?? 0; if (budgetTokens <= 0) return; - const maxTokens = params.max_tokens ?? 0; - const requiredMaxTokens = budgetTokens + OUTPUT_FALLBACK_BUFFER; - if (maxTokens < requiredMaxTokens) { - params.max_tokens = Math.min(requiredMaxTokens, model.maxTokens); + const maxAllowedTokens = Math.min(CLAUDE_CODE_MAX_OUTPUT_TOKENS, model.maxTokens); + const currentMaxTokens = Math.min(params.max_tokens ?? maxAllowedTokens, maxAllowedTokens); + const raisedMaxTokens = Math.min( + Math.max(currentMaxTokens, budgetTokens + OUTPUT_FALLBACK_BUFFER), + maxAllowedTokens, + ); + params.max_tokens = raisedMaxTokens; + + if (budgetTokens + OUTPUT_FALLBACK_BUFFER <= raisedMaxTokens) return; + + const clampedBudget = raisedMaxTokens - OUTPUT_FALLBACK_BUFFER; + if (clampedBudget <= 0) { + throw new Error( + `Anthropic thinking budget requires max_tokens greater than ${OUTPUT_FALLBACK_BUFFER}; got ${raisedMaxTokens}`, + ); } + thinking.budget_tokens = clampedBudget; } type CacheControlBlock = { @@ -2065,39 +2278,35 @@ type CacheControlBlock = { function applyCacheControlToLastBlock( blocks: T[], cacheControl: AnthropicCacheControl, -): void { - if (blocks.length === 0) return; +): boolean { + if (blocks.length === 0) return false; const lastIndex = blocks.length - 1; - blocks[lastIndex] = { ...blocks[lastIndex], cache_control: cacheControl }; + if (blocks[lastIndex].cache_control != null) return false; + blocks[lastIndex] = { ...blocks[lastIndex], cache_control: cloneAnthropicCacheControl(cacheControl) }; + return true; } function applyCacheControlToLastTextBlock( blocks: Array, cacheControl: AnthropicCacheControl, -): void { - if (blocks.length === 0) return; +): boolean { + if (blocks.length === 0) return false; for (let i = blocks.length - 1; i >= 0; i--) { if (blocks[i].type === "text") { - blocks[i] = { ...blocks[i], cache_control: cacheControl }; - return; + if (blocks[i].cache_control != null) return false; + blocks[i] = { ...blocks[i], cache_control: cloneAnthropicCacheControl(cacheControl) }; + return true; } } - applyCacheControlToLastBlock(blocks, cacheControl); + return applyCacheControlToLastBlock(blocks, cacheControl); } function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?: AnthropicCacheControl): void { if (!cacheControl) return; - // Skip if cache_control breakpoints were already placed externally on messages. - for (const message of params.messages) { - if (Array.isArray(message.content)) { - if ((message.content as Array).some(b => b.cache_control != null)) - return; - } - } - const MAX_CACHE_BREAKPOINTS = 4; - let cacheBreakpointsUsed = 0; + let cacheBreakpointsUsed = countCacheControlBreakpoints(params); + if (cacheBreakpointsUsed >= MAX_CACHE_BREAKPOINTS) return; let isCCLayout = false; if (params.system && Array.isArray(params.system) && params.system.length > 0) { @@ -2105,9 +2314,12 @@ function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?: params.system.length >= 3 && (params.system[0] as { text?: string }).text?.startsWith(CLAUDE_BILLING_HEADER_PREFIX) === true; if (isCCLayout) { - cacheBreakpointsUsed += applyClaudeCodeSystemCache(params.system as AnthropicSystemBlock[], cacheControl); - } else { - applyCacheControlToLastBlock(params.system, cacheControl); + const placed = Math.min( + MAX_CACHE_BREAKPOINTS - cacheBreakpointsUsed, + applyClaudeCodeSystemCache(params.system as AnthropicSystemBlock[], cacheControl), + ); + cacheBreakpointsUsed += placed; + } else if (applyCacheControlToLastBlock(params.system, cacheControl)) { cacheBreakpointsUsed++; } } @@ -2120,14 +2332,19 @@ function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?: const message = params.messages[i]; if (!message) continue; if (typeof message.content === "string") { - message.content = [{ type: "text", text: message.content, cache_control: cacheControl }]; + message.content = [ + { type: "text", text: message.content, cache_control: cloneAnthropicCacheControl(cacheControl) }, + ]; cacheBreakpointsUsed++; } else if (Array.isArray(message.content) && message.content.length > 0) { - applyCacheControlToLastTextBlock( - message.content as Array, - cacheControl, - ); - cacheBreakpointsUsed++; + if ( + applyCacheControlToLastTextBlock( + message.content as Array, + cacheControl, + ) + ) { + cacheBreakpointsUsed++; + } } } } @@ -2140,7 +2357,9 @@ function normalizeCacheControlBlockTtl(block: CacheControlBlock, seenFiveMinute: return; } if (seenFiveMinute.value) { - delete cacheControl.ttl; + const normalized = cloneAnthropicCacheControl(cacheControl); + delete normalized.ttl; + block.cache_control = normalized; } } @@ -2305,7 +2524,7 @@ function buildParams( }); // Pre-compute tools. - let tools: ReturnType | undefined; + let tools: AnthropicWireTool[] | undefined; if (context.tools) { tools = convertTools( context.tools, @@ -2318,7 +2537,13 @@ function buildParams( } // Pre-compute metadata. - const metadataUserId = resolveAnthropicMetadataUserId(options?.metadata?.user_id, isOAuthToken, options?.sessionId); + const metadataAccountId = readAnthropicMetadataAccountId(options?.metadata); + const metadataUserId = resolveAnthropicMetadataUserId( + options?.metadata?.user_id, + isOAuthToken, + options?.sessionId, + metadataAccountId, + ); const metadata = metadataUserId ? { user_id: metadataUserId } : undefined; // Pre-compute thinking + output_config effort. @@ -2333,8 +2558,11 @@ function buildParams( const adaptive: { type: "adaptive"; display?: AnthropicThinkingDisplay } = { type: "adaptive" }; // Starting with Claude Opus 4.7, adaptive thinking content is omitted from the // response by default. Opt into summarized reasoning so thinking deltas keep - // streaming with human-readable content for callers that rely on it. - if (options.thinkingDisplay !== undefined || supportsAdaptiveThinkingDisplay(model.id)) { + // streaming with human-readable content for callers that rely on it. The + // `display` field is gated strictly on model support: Opus 4.6 / Sonnet 4.6+ + // reject it with a 400, so an explicit `thinkingDisplay` MUST NOT force it onto + // a model that can't accept it (a hidden-thinking toggle must never break the request). + if (supportsAdaptiveThinkingDisplay(model.id)) { adaptive.display = options.thinkingDisplay ?? "summarized"; } thinking = adaptive; @@ -2368,11 +2596,11 @@ function buildParams( // metadata → max_tokens → thinking → context_management → output_config → stream. const params: MessageCreateParamsStreaming = { model: model.id, - messages: convertAnthropicMessages(context.messages, model, isOAuthToken), + messages: convertAnthropicMessages(context.messages, model, isOAuthToken, baseUrl), ...(systemBlocks && { system: systemBlocks }), ...(tools !== undefined && { tools }), ...(metadata && { metadata }), - max_tokens: Math.min(CLAUDE_CODE_MAX_OUTPUT_TOKENS, options?.maxTokens || model.maxTokens), + max_tokens: Math.min(CLAUDE_CODE_MAX_OUTPUT_TOKENS, model.maxTokens, options?.maxTokens || model.maxTokens), ...(thinking && { thinking }), ...(contextManagement && { context_management: contextManagement }), ...(outputConfig && { output_config: outputConfig }), @@ -2380,8 +2608,10 @@ function buildParams( }; // Opus 4.7+ rejects non-default sampling parameters with 400 error. - const allowSamplingParams = !hasOpus47ApiRestrictions(model.id); - if (allowSamplingParams && options?.temperature !== undefined && !options?.thinkingEnabled) { + const thinkingType = params.thinking?.type; + const allowSamplingParams = + !hasOpus47ApiRestrictions(model.id) && (thinkingType === undefined || thinkingType === "disabled"); + if (allowSamplingParams && options?.temperature !== undefined) { params.temperature = options.temperature; } if (allowSamplingParams && options?.topP !== undefined) { @@ -2459,9 +2689,8 @@ function isZaiAnthropicEndpoint(model: Model<"anthropic-messages">): boolean { * arguments (#2005). Known non-signing hosts are also preserved for * compatibility. */ -function shouldReplayUnsignedThinking(model: Model<"anthropic-messages">): boolean { +function shouldReplayUnsignedThinking(model: Model<"anthropic-messages">, baseUrl: string | undefined): boolean { if (model.provider === "zai" || model.provider === "deepseek") return true; - const baseUrl = model.baseUrl; if (baseUrl) { try { const hostname = new URL(baseUrl).hostname.toLowerCase(); @@ -2497,12 +2726,13 @@ export function convertAnthropicMessages( messages: Message[], model: Model<"anthropic-messages">, isOAuthToken: boolean, + baseUrl = resolveAnthropicBaseUrl(model), ): AnthropicMessageParam[] { - const params: AnthropicMessageParam[] = []; // Indices of params emitted from `developer` messages. After the main pass, // the ones whose placement satisfies Anthropic's mid-conversation rules are // upgraded from the `user` role to the authoritative `system` role. const developerParamIndices: number[] = []; + const params: AnthropicMessageParam[] = []; const transformedMessages = transformMessages(messages, model, normalizeToolCallId); @@ -2561,7 +2791,7 @@ export function convertAnthropicMessages( } if (block.thinking.trim().length === 0) continue; if (!block.thinkingSignature || block.thinkingSignature.trim().length === 0) { - if (shouldReplayUnsignedThinking(model)) { + if (shouldReplayUnsignedThinking(model, baseUrl)) { blocks.push({ type: "thinking", thinking: block.thinking.toWellFormed(), @@ -2711,6 +2941,7 @@ function isJsonSchemaArrayNode(schema: Record): boolean { const t = schema.type; if (t === "array") return true; if (Array.isArray(t) && t.includes("array") && !t.includes("object")) return true; + if (schema.items !== undefined || Array.isArray(schema.prefixItems)) return true; return false; } @@ -2737,6 +2968,13 @@ function pickAnthropicScalarType(type: unknown): string | undefined { } return undefined; } +function pickAnthropicEffectiveScalarType(schema: Record): string | undefined { + const explicit = pickAnthropicScalarType(schema.type); + if (explicit) return explicit; + if (isRecord(schema.properties)) return "object"; + if (schema.items !== undefined || Array.isArray(schema.prefixItems)) return "array"; + return undefined; +} function anthropicPerTypeKeep(scalarType: string | undefined): Set | undefined { switch (scalarType) { @@ -2751,14 +2989,6 @@ function anthropicPerTypeKeep(scalarType: string | undefined): Set | und } } -/** - * Per-schema-object memoization slot for the normalized Anthropic tool form. We stamp - * the result onto the host via a `Symbol` property (mirroring `utils/schema/stamps.ts`) - * instead of using a `WeakMap`: it's a single hidden-class slot, so warm reads are - * direct property access and write-once cycles resolve to the in-progress result. - */ -const kAnthropicToolNormal = Symbol("pi.schema.anthropic.toolNormal"); - /** * Normalize a JSON Schema node for Anthropic tool `input_schema`. * @@ -2779,20 +3009,20 @@ const kAnthropicToolNormal = Symbol("pi.schema.anthropic.toolNormal"); * pass downstream demotes those shapes to non-strict instead of fabricating a closed * object, so callers like the resolve tool keep working open-map semantics. */ -export function normalizeAnthropicToolSchema(schema: unknown): unknown { - if (Array.isArray(schema)) return schema.map(entry => normalizeAnthropicToolSchema(entry)); +function normalizeAnthropicToolSchemaNode( + schema: unknown, + cache: WeakMap, Record>, +): unknown { + if (Array.isArray(schema)) return schema.map(entry => normalizeAnthropicToolSchemaNode(entry, cache)); if (!isRecord(schema)) return schema; - const slot = schema as Record | undefined>; - const existing = slot[kAnthropicToolNormal]; + const existing = cache.get(schema); if (existing !== undefined) return existing; const result: Record = {}; - // Pre-stamp before recursion so cyclic schemas resolve to the in-progress object - // (mirrors the WeakMap-set-before-recurse pattern the original implementation used). - Object.defineProperty(schema, kAnthropicToolNormal, { value: result, writable: true, configurable: true }); + cache.set(schema, result); - const scalarType = pickAnthropicScalarType(schema.type); + const scalarType = pickAnthropicEffectiveScalarType(schema); const perTypeKeep = anthropicPerTypeKeep(scalarType); const spill: Array<[string, unknown]> = []; @@ -2831,12 +3061,12 @@ export function normalizeAnthropicToolSchema(schema: unknown): unknown { const sourceProperties = result.properties as Record; for (const propName in sourceProperties) { if (!Object.hasOwn(sourceProperties, propName)) continue; - normalizedProperties[propName] = normalizeAnthropicToolSchema(sourceProperties[propName]); + normalizedProperties[propName] = normalizeAnthropicToolSchemaNode(sourceProperties[propName], cache); } result.properties = normalizedProperties; } if (isRecord(result.additionalProperties)) { - const normalized = normalizeAnthropicToolSchema(result.additionalProperties); + const normalized = normalizeAnthropicToolSchemaNode(result.additionalProperties, cache); if (isRecord(normalized) && Object.keys(normalized).length === 0) { result.additionalProperties = true; } else { @@ -2844,17 +3074,17 @@ export function normalizeAnthropicToolSchema(schema: unknown): unknown { } } if (Array.isArray(result.items)) { - result.items = result.items.map(item => normalizeAnthropicToolSchema(item)); + result.items = result.items.map(item => normalizeAnthropicToolSchemaNode(item, cache)); } else if (isRecord(result.items)) { - result.items = normalizeAnthropicToolSchema(result.items); + result.items = normalizeAnthropicToolSchemaNode(result.items, cache); } if (Array.isArray(result.prefixItems)) { - result.prefixItems = result.prefixItems.map(item => normalizeAnthropicToolSchema(item)); + result.prefixItems = result.prefixItems.map(item => normalizeAnthropicToolSchemaNode(item, cache)); } for (const key of COMBINATOR_KEYS) { const variants = result[key]; if (Array.isArray(variants)) { - result[key] = variants.map(variant => normalizeAnthropicToolSchema(variant)); + result[key] = variants.map(variant => normalizeAnthropicToolSchemaNode(variant, cache)); } } for (const defsKey of ["$defs", "definitions"] as const) { @@ -2864,7 +3094,7 @@ export function normalizeAnthropicToolSchema(schema: unknown): unknown { const sourceDefs = definitions as Record; for (const name in sourceDefs) { if (!Object.hasOwn(sourceDefs, name)) continue; - normalizedDefs[name] = normalizeAnthropicToolSchema(sourceDefs[name]); + normalizedDefs[name] = normalizeAnthropicToolSchemaNode(sourceDefs[name], cache); } result[defsKey] = normalizedDefs; } @@ -2873,6 +3103,10 @@ export function normalizeAnthropicToolSchema(schema: unknown): unknown { return result; } +export function normalizeAnthropicToolSchema(schema: unknown): unknown { + return normalizeAnthropicToolSchemaNode(schema, new WeakMap()); +} + type AnthropicToolSchemaPlan = { inputSchema: AnthropicToolInputSchema; strict: boolean; @@ -2893,6 +3127,24 @@ function hasNullVariant(schema: Record): boolean { if (Array.isArray(schema.type) && schema.type.includes("null")) return true; return Array.isArray(schema.anyOf) && schema.anyOf.some(variant => isRecord(variant) && variant.type === "null"); } +function hasAnthropicSchemaDefiningKeyword(schema: Record): boolean { + if ( + schema.type !== undefined || + schema.properties !== undefined || + schema.additionalProperties !== undefined || + schema.items !== undefined || + schema.prefixItems !== undefined || + schema.enum !== undefined || + schema.const !== undefined || + schema.$ref !== undefined + ) { + return true; + } + for (const key of COMBINATOR_KEYS) { + if (schema[key] !== undefined) return true; + } + return schema.$defs !== undefined || schema.definitions !== undefined; +} function makeAnthropicNullableSchema(schema: unknown, budget: AnthropicStrictBudget): unknown | undefined { if (isRecord(schema)) { @@ -2931,6 +3183,8 @@ function normalizeAnthropicStrictSchemaNode( const cached = cache.get(schema); if (cached) return cached; + if (!hasAnthropicSchemaDefiningKeyword(schema)) return undefined; + // Strict tool use only supports closed objects. Open maps stay available on // the non-strict schema plan instead of producing an Anthropic 400. if (isJsonSchemaObjectNode(schema) && schema.additionalProperties !== false) { diff --git a/packages/ai/src/providers/github-copilot-headers.ts b/packages/ai/src/providers/github-copilot-headers.ts index c66fa8290..39576c369 100644 --- a/packages/ai/src/providers/github-copilot-headers.ts +++ b/packages/ai/src/providers/github-copilot-headers.ts @@ -1,5 +1,5 @@ +import { getGitHubCopilotBaseUrl, parseGitHubCopilotApiKey } from "../registry/oauth/github-copilot"; import type { Message } from "../types"; -import { getGitHubCopilotBaseUrl, parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot"; /** * Infer whether the current request to Copilot is user-initiated or agent-initiated. * Accepts `unknown[]` because providers may pass pre-converted message shapes. diff --git a/packages/ai/src/providers/kimi.ts b/packages/ai/src/providers/kimi.ts index af98f4f34..276fe3da4 100644 --- a/packages/ai/src/providers/kimi.ts +++ b/packages/ai/src/providers/kimi.ts @@ -9,9 +9,9 @@ * Note: Kimi calculates TPM rate limits based on max_tokens, not actual output. */ +import { getKimiCommonHeaders } from "../registry/oauth/kimi"; import type { Api, Context, Model } from "../types"; import type { AssistantMessageEventStream } from "../utils/event-stream"; -import { getKimiCommonHeaders } from "../utils/oauth/kimi"; import { type OpenAIAnthropicApiFormat, type OpenAIAnthropicShimOptions, diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index f8eaedffe..05625372e 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -142,6 +142,12 @@ const CODEX_WEBSOCKET_FATAL_PATTERNS = ["websocket error:", "websocket closed be /** Max total time to spend retrying 429s with server-provided delays (5 minutes). */ const CODEX_RATE_LIMIT_BUDGET_MS = 5 * 60 * 1000; const CODEX_ADDITIONAL_PROGRESS_EVENT_TYPES = new Set(["response.done", "response.incomplete"]); +// Provider/model failure mode: Codex can keep a response alive by streaming +// whitespace-only function-call argument deltas forever. Those frames count as +// transport activity, so idle timers never fire; cap the run before raw debug +// buffers and partial JSON grow without semantic progress. +const CODEX_WHITESPACE_TOOL_CALL_ARGUMENT_DELTA_EVENT_LIMIT = 256; +const CODEX_WHITESPACE_TOOL_CALL_ARGUMENT_DELTA_CHAR_LIMIT = 16 * 1024; function isCodexStreamProgressEvent(event: unknown): boolean { if (isOpenAIResponsesProgressEvent(event)) return true; @@ -235,6 +241,20 @@ interface CodexStreamRuntime { providerRetryAttempt: number; sawTerminalEvent: boolean; canSafelyReplayWebsocketOverSse: boolean; + whitespaceToolCallArgumentsDelta?: CodexWhitespaceToolCallArgumentsDeltaState; +} + +interface CodexWhitespaceToolCallArgumentsDeltaState { + itemId: string; + outputIndex?: number; + consecutiveEvents: number; + consecutiveChars: number; + firstSequenceNumber?: number; + lastSequenceNumber?: number; +} + +interface CodexWhitespaceToolCallArgumentsDeltaInterruption { + message: string; } interface CodexStreamProcessingContext { @@ -945,9 +965,88 @@ function createCodexStreamRuntime(initial: { providerRetryAttempt: 0, sawTerminalEvent: false, canSafelyReplayWebsocketOverSse: true, + whitespaceToolCallArgumentsDelta: undefined, }; } +function resetWhitespaceToolCallArgumentsDelta(runtime: CodexStreamRuntime): void { + runtime.whitespaceToolCallArgumentsDelta = undefined; +} + +function isJsonWhitespaceOnly(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code !== 0x09 && code !== 0x0a && code !== 0x0d && code !== 0x20) { + return false; + } + } + return true; +} + +function readOptionalInteger(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + return Math.trunc(value); +} + +function observeWhitespaceToolCallArgumentsDelta( + runtime: CodexStreamRuntime, + rawEvent: Record, + delta: string, +): CodexWhitespaceToolCallArgumentsDeltaInterruption | undefined { + if (!isJsonWhitespaceOnly(delta)) { + resetWhitespaceToolCallArgumentsDelta(runtime); + return undefined; + } + + const itemId = + typeof rawEvent.item_id === "string" && rawEvent.item_id.length > 0 + ? rawEvent.item_id + : (runtime.currentItem?.id ?? ""); + const outputIndex = readOptionalInteger(rawEvent.output_index); + const sequenceNumber = readOptionalInteger(rawEvent.sequence_number); + let state = runtime.whitespaceToolCallArgumentsDelta; + if (!state || state.itemId !== itemId || state.outputIndex !== outputIndex) { + state = { + itemId, + outputIndex, + consecutiveEvents: 0, + consecutiveChars: 0, + firstSequenceNumber: sequenceNumber, + }; + runtime.whitespaceToolCallArgumentsDelta = state; + } + + state.consecutiveEvents += 1; + state.consecutiveChars += delta.length; + state.lastSequenceNumber = sequenceNumber; + if ( + state.consecutiveEvents < CODEX_WHITESPACE_TOOL_CALL_ARGUMENT_DELTA_EVENT_LIMIT && + state.consecutiveChars < CODEX_WHITESPACE_TOOL_CALL_ARGUMENT_DELTA_CHAR_LIMIT + ) { + return undefined; + } + + const itemLabel = itemId ? ` for item ${itemId}` : ""; + const sequenceLabel = + state.firstSequenceNumber === undefined || state.lastSequenceNumber === undefined + ? "" + : `, sequence ${state.firstSequenceNumber}..${state.lastSequenceNumber}`; + return { + message: `Interrupted OpenAI Codex response after ${state.consecutiveEvents} consecutive whitespace-only tool-call argument delta events (${state.consecutiveChars} chars${sequenceLabel})${itemLabel}.`, + }; +} + +function interruptWhitespaceToolCallArgumentsDelta( + requestSetup: CodexRequestSetup, + runtime: CodexStreamRuntime, + interruption: CodexWhitespaceToolCallArgumentsDeltaInterruption, +): never { + const error = new Error(interruption.message); + requestSetup.requestAbortController.abort(error); + runtime.websocketState?.connection?.close("degenerate-tool-call"); + throw error; +} + async function processCodexResponseStream( context: CodexStreamProcessingContext, runtime: CodexStreamRuntime, @@ -984,8 +1083,9 @@ function handleCodexStreamEvent(args: { runtime: CodexStreamRuntime; rawEvent: Record; firstTokenTime?: number; + requestSetup: CodexRequestSetup; }): number | undefined { - const { model, output, stream, runtime, rawEvent } = args; + const { model, output, stream, runtime, rawEvent, requestSetup } = args; const eventType = typeof rawEvent.type === "string" ? rawEvent.type : ""; if (!eventType) return args.firstTokenTime; @@ -994,6 +1094,7 @@ function handleCodexStreamEvent(args: { let firstTokenTime = args.firstTokenTime; if (eventType === "response.output_item.added") { + resetWhitespaceToolCallArgumentsDelta(runtime); if (!firstTokenTime) firstTokenTime = Date.now(); const item = rawEvent.item as CodexEventItem; runtime.currentItem = item; @@ -1055,11 +1156,13 @@ function handleCodexStreamEvent(args: { } if (eventType === "response.function_call_arguments.delta") { - handleToolCallArgumentsDelta(runtime.currentItem, runtime.currentBlock, rawEvent, stream, output, blockIndex); + const interruption = handleToolCallArgumentsDelta(runtime, rawEvent, stream, output, blockIndex); + if (interruption) interruptWhitespaceToolCallArgumentsDelta(requestSetup, runtime, interruption); return firstTokenTime; } if (eventType === "response.function_call_arguments.done") { + resetWhitespaceToolCallArgumentsDelta(runtime); handleToolCallArgumentsDone(runtime.currentItem, runtime.currentBlock, rawEvent); return firstTokenTime; } @@ -1075,6 +1178,7 @@ function handleCodexStreamEvent(args: { } if (eventType === "response.output_item.done") { + resetWhitespaceToolCallArgumentsDelta(runtime); handleOutputItemDone(model, output, stream, runtime, rawEvent, blockIndex); return firstTokenTime; } @@ -1206,15 +1310,18 @@ function handleMessageTextDelta( } function handleToolCallArgumentsDelta( - currentItem: CodexEventItem | null, - currentBlock: CodexOutputBlock | null, + runtime: CodexStreamRuntime, rawEvent: Record, stream: AssistantMessageEventStream, output: AssistantMessage, blockIndex: () => number, -): void { - if (currentItem?.type !== "function_call" || currentBlock?.type !== "toolCall") return; +): CodexWhitespaceToolCallArgumentsDeltaInterruption | undefined { + const currentItem = runtime.currentItem; + const currentBlock = runtime.currentBlock; + if (currentItem?.type !== "function_call" || currentBlock?.type !== "toolCall") return undefined; const delta = (rawEvent as { delta?: string }).delta || ""; + const interruption = observeWhitespaceToolCallArgumentsDelta(runtime, rawEvent, delta); + if (interruption) return interruption; currentBlock.partialJson += delta; const throttled = parseStreamingJsonThrottled(currentBlock.partialJson, currentBlock.lastParseLen ?? 0); if (throttled) { @@ -1222,6 +1329,7 @@ function handleToolCallArgumentsDelta( currentBlock.lastParseLen = throttled.parsedLen; } stream.push({ type: "toolcall_delta", contentIndex: blockIndex(), delta, partial: output }); + return undefined; } function handleToolCallArgumentsDone( diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index f75174c91..e29ac13d5 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -13,6 +13,8 @@ import packageJson from "../../package.json" with { type: "json" }; import type { Effort } from "../effort"; import { getSupportedEfforts } from "../model-thinking"; import { calculateCost } from "../models"; +import { parseGitHubCopilotApiKey } from "../registry/oauth/github-copilot"; +import { getKimiCommonHeaders } from "../registry/oauth/kimi"; import { getEnvApiKey } from "../stream"; import { type AssistantMessage, @@ -53,8 +55,6 @@ import { iterateWithIdleTimeout, } from "../utils/idle-iterator"; import { parseStreamingJson, parseStreamingJsonThrottled } from "../utils/json-parse"; -import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot"; -import { getKimiCommonHeaders } from "../utils/oauth/kimi"; import { notifyProviderResponse } from "../utils/provider-response"; import { callWithCopilotModelRetry } from "../utils/retry"; import { adaptSchemaForStrict, NO_STRICT, toolWireSchema } from "../utils/schema"; diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index f3f251099..897056dbd 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -6,6 +6,7 @@ import type { ResponseInput, ResponseStreamEvent, } from "openai/resources/responses/responses"; +import { parseGitHubCopilotApiKey } from "../registry/oauth/github-copilot"; import { getEnvApiKey } from "../stream"; import type { AssistantMessage, @@ -39,7 +40,6 @@ import { getOpenAIStreamIdleTimeoutMs, iterateWithIdleTimeout, } from "../utils/idle-iterator"; -import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot"; import { notifyProviderResponse } from "../utils/provider-response"; import { callWithCopilotModelRetry } from "../utils/retry"; import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema"; diff --git a/packages/ai/src/utils/oauth/alibaba-coding-plan.ts b/packages/ai/src/registry/alibaba-coding-plan.ts similarity index 60% rename from packages/ai/src/utils/oauth/alibaba-coding-plan.ts rename to packages/ai/src/registry/alibaba-coding-plan.ts index 5d79dc2ea..c9dc04878 100644 --- a/packages/ai/src/utils/oauth/alibaba-coding-plan.ts +++ b/packages/ai/src/registry/alibaba-coding-plan.ts @@ -1,27 +1,12 @@ -/** - * Alibaba Coding Plan login flow. - * - * Alibaba Coding Plan provides OpenAI-compatible models via https://coding-intl.dashscope.aliyuncs.com/v1. - * - * This is not OAuth - it's a simple API key flow: - * 1. Open browser to Alibaba Cloud DashScope API key settings - * 2. User copies their API key - * 3. User pastes the API key into the CLI - */ - +import { alibabaCodingPlanModelManagerOptions } from "../provider-models/openai-compat"; import { validateOpenAICompatibleApiKey } from "./api-key-validation"; -import type { OAuthController } from "./types"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const AUTH_URL = "https://modelstudio.console.alibabacloud.com/"; const API_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1"; const VALIDATION_MODEL = "qwen3.5-plus"; -/** - * Login to Alibaba Coding Plan. - * - * Opens browser to API keys page, prompts user to paste their API key. - * Returns the API key directly (not OAuthCredentials - this isn't OAuth). - */ export async function loginAlibabaCodingPlan(options: OAuthController): Promise { if (!options.onPrompt) { throw new Error("Alibaba Coding Plan login requires onPrompt callback"); @@ -57,3 +42,13 @@ export async function loginAlibabaCodingPlan(options: OAuthController): Promise< return trimmed; } + +export const alibabaCodingPlanProvider = { + id: "alibaba-coding-plan", + name: "Alibaba Coding Plan", + defaultModel: "qwen3.5-plus", + createModelManagerOptions: (config: ModelManagerConfig) => alibabaCodingPlanModelManagerOptions(config), + catalogDiscovery: { label: "Alibaba Coding Plan", envVars: ["ALIBABA_CODING_PLAN_API_KEY"] }, + envKeys: "ALIBABA_CODING_PLAN_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginAlibabaCodingPlan(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/amazon-bedrock.ts b/packages/ai/src/registry/amazon-bedrock.ts new file mode 100644 index 000000000..82fd01cdb --- /dev/null +++ b/packages/ai/src/registry/amazon-bedrock.ts @@ -0,0 +1,23 @@ +import { $env } from "@oh-my-pi/pi-utils"; +import type { ProviderDefinition } from "./types"; + +export const amazonBedrockProvider = { + id: "amazon-bedrock", + name: "Amazon Bedrock", + defaultModel: "us.anthropic.claude-opus-4-6-v1", + // Amazon Bedrock accepts bearer tokens, IAM keys, profiles, ECS/IRSA credential chains. + envKeys: () => { + const hasEcsCredentials = + !!$env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || !!$env.AWS_CONTAINER_CREDENTIALS_FULL_URI; + const hasWebIdentity = !!$env.AWS_WEB_IDENTITY_TOKEN_FILE && !!$env.AWS_ROLE_ARN; + if ( + $env.AWS_PROFILE || + ($env.AWS_ACCESS_KEY_ID && $env.AWS_SECRET_ACCESS_KEY) || + $env.AWS_BEARER_TOKEN_BEDROCK || + hasEcsCredentials || + hasWebIdentity + ) { + return ""; + } + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/anthropic.ts b/packages/ai/src/registry/anthropic.ts new file mode 100644 index 000000000..f53b6d7c0 --- /dev/null +++ b/packages/ai/src/registry/anthropic.ts @@ -0,0 +1,29 @@ +import { $pickenv } from "@oh-my-pi/pi-utils"; +import { anthropicModelManagerOptions } from "../provider-models/openai-compat"; +import { isFoundryEnabled } from "../utils/foundry"; +import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const anthropicProvider = { + id: "anthropic", + name: "Anthropic (Claude Pro/Max)", + defaultModel: "claude-opus-4-6", + createModelManagerOptions: (config: ModelManagerConfig) => anthropicModelManagerOptions(config), + // Foundry mode optionally switches Anthropic auth to enterprise gateway credentials. + envKeys: () => + isFoundryEnabled() + ? $pickenv("ANTHROPIC_FOUNDRY_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY") + : $pickenv("ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"), + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginAnthropic } = await import("./oauth/anthropic"); + return loginAnthropic(cb); + }, + refreshToken: async (credentials: OAuthCredentials) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { refreshAnthropicToken } = await import("./oauth/anthropic"); + return refreshAnthropicToken(credentials.refresh); + }, + callbackPort: 54545, + pasteCodeFlow: true, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/api-key-login.ts b/packages/ai/src/registry/api-key-login.ts similarity index 76% rename from packages/ai/src/utils/oauth/api-key-login.ts rename to packages/ai/src/registry/api-key-login.ts index f68afa43f..514b6e15a 100644 --- a/packages/ai/src/utils/oauth/api-key-login.ts +++ b/packages/ai/src/registry/api-key-login.ts @@ -6,8 +6,12 @@ * optionally validate it, and return the trimmed key. */ -import { validateApiKeyAgainstModelsEndpoint, validateOpenAICompatibleApiKey } from "./api-key-validation"; -import type { OAuthController } from "./types"; +import { + validateAnthropicCompatibleApiKey, + validateApiKeyAgainstModelsEndpoint, + validateOpenAICompatibleApiKey, +} from "./api-key-validation"; +import type { OAuthController } from "./oauth/types"; type ChatCompletionsValidation = { kind: "chat-completions"; @@ -16,6 +20,13 @@ type ChatCompletionsValidation = { model: string; }; +type AnthropicMessagesValidation = { + kind: "anthropic-messages"; + provider: string; + baseUrl: string; + model: string; +}; + type ModelsEndpointValidation = { kind: "models-endpoint"; provider: string; @@ -34,7 +45,7 @@ export type ApiKeyLoginConfig = { /** Placeholder string for the prompt (e.g. "sk-...", "csk-..."). */ placeholder: string; /** Validation strategy, or `null` to skip validation. */ - validation: ChatCompletionsValidation | ModelsEndpointValidation | null; + validation: ChatCompletionsValidation | AnthropicMessagesValidation | ModelsEndpointValidation | null; }; export function createApiKeyLogin(config: ApiKeyLoginConfig): (options: OAuthController) => Promise { @@ -72,6 +83,14 @@ export function createApiKeyLogin(config: ApiKeyLoginConfig): (options: OAuthCon model: config.validation.model, signal: options.signal, }); + } else if (config.validation.kind === "anthropic-messages") { + await validateAnthropicCompatibleApiKey({ + provider: config.validation.provider, + apiKey: trimmed, + baseUrl: config.validation.baseUrl, + model: config.validation.model, + signal: options.signal, + }); } else { await validateApiKeyAgainstModelsEndpoint({ provider: config.validation.provider, diff --git a/packages/ai/src/utils/oauth/api-key-validation.ts b/packages/ai/src/registry/api-key-validation.ts similarity index 62% rename from packages/ai/src/utils/oauth/api-key-validation.ts rename to packages/ai/src/registry/api-key-validation.ts index baeacc590..e1879036f 100644 --- a/packages/ai/src/utils/oauth/api-key-validation.ts +++ b/packages/ai/src/registry/api-key-validation.ts @@ -5,6 +5,13 @@ type OpenAICompatibleValidationOptions = { model: string; signal?: AbortSignal; }; +type AnthropicCompatibleValidationOptions = { + provider: string; + apiKey: string; + baseUrl: string; + model: string; + signal?: AbortSignal; +}; type ModelListValidationOptions = { provider: string; @@ -15,6 +22,11 @@ type ModelListValidationOptions = { const VALIDATION_TIMEOUT_MS = 15_000; +function normalizeAnthropicCompatibleBaseUrl(baseUrl: string): string { + const trimmed = baseUrl.trim().replace(/\/+$/, ""); + return trimmed.endsWith("/v1") ? trimmed.slice(0, -3) : trimmed; +} + /** * Validate an API key against an OpenAI-compatible chat completions endpoint. * @@ -56,6 +68,46 @@ export async function validateOpenAICompatibleApiKey(options: OpenAICompatibleVa throw new Error(message); } +/** + * Validate an API key against an Anthropic-compatible messages endpoint. + */ +export async function validateAnthropicCompatibleApiKey(options: AnthropicCompatibleValidationOptions): Promise { + const timeoutSignal = AbortSignal.timeout(VALIDATION_TIMEOUT_MS); + const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal; + const baseUrl = normalizeAnthropicCompatibleBaseUrl(options.baseUrl); + + const response = await fetch(`${baseUrl}/v1/messages`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "anthropic-version": "2023-06-01", + "x-api-key": options.apiKey, + }, + body: JSON.stringify({ + model: options.model, + messages: [{ role: "user", content: "ping" }], + max_tokens: 1, + }), + signal, + }); + + if (response.ok) { + return; + } + + let details = ""; + try { + details = (await response.text()).trim(); + } catch { + // ignore body parse errors, status is enough + } + + const message = details + ? `${options.provider} API key validation failed (${response.status}): ${details}` + : `${options.provider} API key validation failed (${response.status})`; + throw new Error(message); +} + /** * Validate an API key against a provider models endpoint. * diff --git a/packages/ai/src/registry/cerebras.ts b/packages/ai/src/registry/cerebras.ts new file mode 100644 index 000000000..98016c366 --- /dev/null +++ b/packages/ai/src/registry/cerebras.ts @@ -0,0 +1,28 @@ +import { cerebrasModelManagerOptions } from "../provider-models/openai-compat"; +import { createApiKeyLogin } from "./api-key-login"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const loginCerebras = createApiKeyLogin({ + providerLabel: "Cerebras", + authUrl: "https://cloud.cerebras.ai/platform/", + instructions: "Copy your API key from the Cerebras dashboard", + promptMessage: "Paste your Cerebras API key", + placeholder: "csk-...", + validation: { + kind: "chat-completions", + provider: "Cerebras", + baseUrl: "https://api.cerebras.ai/v1", + model: "gpt-oss-120b", + }, +}); + +export const cerebrasProvider = { + id: "cerebras", + name: "Cerebras", + defaultModel: "zai-glm-4.6", + createModelManagerOptions: (config: ModelManagerConfig) => cerebrasModelManagerOptions(config), + catalogDiscovery: { label: "Cerebras", envVars: ["CEREBRAS_API_KEY"] }, + envKeys: "CEREBRAS_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginCerebras(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/cloudflare-ai-gateway.ts b/packages/ai/src/registry/cloudflare-ai-gateway.ts similarity index 57% rename from packages/ai/src/utils/oauth/cloudflare-ai-gateway.ts rename to packages/ai/src/registry/cloudflare-ai-gateway.ts index 9b564cf76..c0b64ab89 100644 --- a/packages/ai/src/utils/oauth/cloudflare-ai-gateway.ts +++ b/packages/ai/src/registry/cloudflare-ai-gateway.ts @@ -1,15 +1,6 @@ -/** - * Cloudflare AI Gateway login flow. - * - * Cloudflare AI Gateway proxies upstream model providers. - * - * This is not OAuth - it's a simple API key flow: - * 1. Open Cloudflare AI Gateway docs/dashboard - * 2. User copies their Cloudflare AI Gateway token/API key - * 3. User pastes the API key into the CLI - */ - -import type { OAuthController } from "./types"; +import { cloudflareAiGatewayModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const AUTH_URL = "https://developers.cloudflare.com/ai-gateway/configuration/authentication/"; @@ -46,3 +37,13 @@ export async function loginCloudflareAiGateway(options: OAuthController): Promis return trimmed; } + +export const cloudflareAiGatewayProvider = { + id: "cloudflare-ai-gateway", + name: "Cloudflare AI Gateway", + defaultModel: "claude-sonnet-4-5", + createModelManagerOptions: (config: ModelManagerConfig) => cloudflareAiGatewayModelManagerOptions(config), + catalogDiscovery: { label: "Cloudflare AI Gateway", envVars: ["CLOUDFLARE_AI_GATEWAY_API_KEY"] }, + envKeys: "CLOUDFLARE_AI_GATEWAY_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginCloudflareAiGateway(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/cursor.ts b/packages/ai/src/registry/cursor.ts new file mode 100644 index 000000000..9d143e768 --- /dev/null +++ b/packages/ai/src/registry/cursor.ts @@ -0,0 +1,25 @@ +import { cursorModelManagerOptions } from "../provider-models/special"; +import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const cursorProvider = { + id: "cursor", + name: "Cursor (Claude, GPT, etc.)", + defaultModel: "claude-sonnet-4-6", + createModelManagerOptions: (config: ModelManagerConfig) => cursorModelManagerOptions(config), + catalogDiscovery: { label: "Cursor", envVars: ["CURSOR_API_KEY"], oauthProvider: "cursor" }, + envKeys: "CURSOR_ACCESS_TOKEN", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginCursor } = await import("./oauth/cursor"); + return loginCursor( + url => cb.onAuth({ url }), + cb.onProgress ? () => cb.onProgress?.("Waiting for browser authentication...") : undefined, + ); + }, + refreshToken: async (credentials: OAuthCredentials) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { refreshCursorToken } = await import("./oauth/cursor"); + return refreshCursorToken(credentials.refresh); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/deepseek.ts b/packages/ai/src/registry/deepseek.ts similarity index 57% rename from packages/ai/src/utils/oauth/deepseek.ts rename to packages/ai/src/registry/deepseek.ts index e0b6e2869..669f4288b 100644 --- a/packages/ai/src/utils/oauth/deepseek.ts +++ b/packages/ai/src/registry/deepseek.ts @@ -1,13 +1,7 @@ -/** - * DeepSeek login flow (API key paste against https://api.deepseek.com). - * - * Validation hits `GET /v1/models` so it authenticates the key without - * depending on a specific model being enabled on the account. The previous - * implementation issued a chat-completion against `deepseek-v4-pro`, which - * 404s for accounts without that preview model even when the key is valid. - */ +import { deepseekModelManagerOptions } from "../provider-models/openai-compat"; import { createApiKeyLogin } from "./api-key-login"; -import type { OAuthController, OAuthPrompt } from "./types"; +import type { OAuthController, OAuthLoginCallbacks, OAuthPrompt } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const innerLogin = createApiKeyLogin({ providerLabel: "DeepSeek", @@ -22,17 +16,10 @@ const innerLogin = createApiKeyLogin({ }, }); -/** - * Normalize a pasted DeepSeek API key. - * - * Users frequently copy keys out of `curl` snippets that include the - * `Authorization: Bearer …` prefix. Strip it so validation does not fail - * with a confusing 401, and reject obviously empty input early. - */ export function normalizeDeepSeekApiKey(raw: string): string { const trimmed = raw.trim(); if (!trimmed) { - return trimmed; // let the shared factory throw the canonical "API key is required" + return trimmed; } const stripped = trimmed.replace(/^bearer\b\s*/i, ""); if (!stripped) { @@ -51,3 +38,13 @@ export const loginDeepSeek = async (options: OAuthController): Promise = : options; return innerLogin(wrapped); }; + +export const deepseekProvider = { + id: "deepseek", + name: "DeepSeek", + defaultModel: "deepseek-v4-pro", + createModelManagerOptions: (config: ModelManagerConfig) => deepseekModelManagerOptions(config), + catalogDiscovery: { label: "DeepSeek", envVars: ["DEEPSEEK_API_KEY"] }, + envKeys: "DEEPSEEK_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginDeepSeek(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/derived.ts b/packages/ai/src/registry/derived.ts new file mode 100644 index 000000000..d72d6372c --- /dev/null +++ b/packages/ai/src/registry/derived.ts @@ -0,0 +1,9 @@ +import { PROVIDER_REGISTRY } from "./registry"; + +/** + * Providers whose OAuth flow needs a pasted code/redirect URL rather than a + * local callback server. Consumed by the coding-agent login UX. + */ +export const PASTE_CODE_LOGIN_PROVIDERS: ReadonlySet = new Set( + PROVIDER_REGISTRY.filter(p => p.pasteCodeFlow).map(p => p.id), +); diff --git a/packages/ai/src/utils/oauth/firepass.ts b/packages/ai/src/registry/firepass.ts similarity index 61% rename from packages/ai/src/utils/oauth/firepass.ts rename to packages/ai/src/registry/firepass.ts index 57689b681..3599c2a38 100644 --- a/packages/ai/src/utils/oauth/firepass.ts +++ b/packages/ai/src/registry/firepass.ts @@ -1,3 +1,8 @@ +import { firepassModelManagerOptions } from "../provider-models/openai-compat"; +import { createApiKeyLogin } from "./api-key-login"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + /** * Fire Pass login flow. * @@ -7,8 +12,6 @@ * pings the chat completions endpoint with the router id directly. * See https://docs.fireworks.ai/firepass. */ -import { createApiKeyLogin } from "./api-key-login"; - export const loginFirepass = createApiKeyLogin({ providerLabel: "Fire Pass", authUrl: "https://app.fireworks.ai/settings/users/api-keys", @@ -22,3 +25,12 @@ export const loginFirepass = createApiKeyLogin({ model: "accounts/fireworks/routers/kimi-k2p6-turbo", }, }); + +export const firepassProvider = { + id: "firepass", + name: "Fire Pass (Fireworks Kimi K2.6 Turbo subscription)", + defaultModel: "kimi-k2.6-turbo", + createModelManagerOptions: (config: ModelManagerConfig) => firepassModelManagerOptions(config), + envKeys: "FIREPASS_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginFirepass(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/fireworks.ts b/packages/ai/src/registry/fireworks.ts new file mode 100644 index 000000000..20d17d91d --- /dev/null +++ b/packages/ai/src/registry/fireworks.ts @@ -0,0 +1,27 @@ +import { fireworksModelManagerOptions } from "../provider-models/openai-compat"; +import { createApiKeyLogin } from "./api-key-login"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const loginFireworks = createApiKeyLogin({ + providerLabel: "Fireworks", + authUrl: "https://app.fireworks.ai/settings/users/api-keys", + instructions: "Create or copy your Fireworks API key", + promptMessage: "Paste your Fireworks API key", + placeholder: "fw_...", + validation: { + kind: "models-endpoint", + provider: "Fireworks", + modelsUrl: "https://api.fireworks.ai/inference/v1/models", + }, +}); + +export const fireworksProvider = { + id: "fireworks", + name: "Fireworks", + defaultModel: "kimi-k2.6", + createModelManagerOptions: (config: ModelManagerConfig) => fireworksModelManagerOptions(config), + catalogDiscovery: { label: "Fireworks", envVars: ["FIREWORKS_API_KEY"] }, + envKeys: "FIREWORKS_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginFireworks(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/github-copilot.ts b/packages/ai/src/registry/github-copilot.ts new file mode 100644 index 000000000..b8e757ac6 --- /dev/null +++ b/packages/ai/src/registry/github-copilot.ts @@ -0,0 +1,26 @@ +import { githubCopilotModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const githubCopilotProvider = { + id: "github-copilot", + name: "GitHub Copilot", + defaultModel: "gpt-4o", + createModelManagerOptions: (config: ModelManagerConfig) => githubCopilotModelManagerOptions(config), + envKeys: "COPILOT_GITHUB_TOKEN", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginGitHubCopilot } = await import("./oauth/github-copilot"); + return loginGitHubCopilot({ + onAuth: (url, instructions) => cb.onAuth({ url, instructions }), + onPrompt: cb.onPrompt, + onProgress: cb.onProgress, + signal: cb.signal, + }); + }, + refreshToken: async (credentials: OAuthCredentials) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { refreshGitHubCopilotToken } = await import("./oauth/github-copilot"); + return refreshGitHubCopilotToken(credentials.refresh, credentials.enterpriseUrl); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/gitlab-duo.ts b/packages/ai/src/registry/gitlab-duo.ts new file mode 100644 index 000000000..aa6bf3775 --- /dev/null +++ b/packages/ai/src/registry/gitlab-duo.ts @@ -0,0 +1,21 @@ +import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; +import type { ProviderDefinition } from "./types"; + +export const gitlabDuoProvider = { + id: "gitlab-duo", + name: "GitLab Duo", + defaultModel: "duo-chat-sonnet-4-5", + envKeys: "GITLAB_TOKEN", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginGitLabDuo } = await import("./oauth/gitlab-duo"); + return loginGitLabDuo(cb); + }, + refreshToken: async (credentials: OAuthCredentials) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { refreshGitLabDuoToken } = await import("./oauth/gitlab-duo"); + return refreshGitLabDuoToken(credentials); + }, + callbackPort: 8080, + pasteCodeFlow: true, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/google-antigravity.ts b/packages/ai/src/registry/google-antigravity.ts new file mode 100644 index 000000000..beeda90cc --- /dev/null +++ b/packages/ai/src/registry/google-antigravity.ts @@ -0,0 +1,23 @@ +import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; +import type { ProviderDefinition } from "./types"; + +export const googleAntigravityProvider = { + id: "google-antigravity", + name: "Antigravity (Gemini 3, Claude, GPT-OSS)", + defaultModel: "gemini-3-pro-high", + specialModelManager: true, + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginAntigravity } = await import("./oauth/google-antigravity"); + return loginAntigravity(cb); + }, + refreshToken: async (credentials: OAuthCredentials) => { + if (!credentials.projectId) { + throw new Error("Antigravity credentials missing projectId"); + } + const { refreshAntigravityToken } = await import("./oauth/google-antigravity"); + return refreshAntigravityToken(credentials.refresh, credentials.projectId); + }, + callbackPort: 51121, + pasteCodeFlow: true, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/google-gemini-cli.ts b/packages/ai/src/registry/google-gemini-cli.ts new file mode 100644 index 000000000..e0552340f --- /dev/null +++ b/packages/ai/src/registry/google-gemini-cli.ts @@ -0,0 +1,23 @@ +import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; +import type { ProviderDefinition } from "./types"; + +export const googleGeminiCliProvider = { + id: "google-gemini-cli", + name: "Google Cloud Code Assist (Gemini CLI)", + defaultModel: "gemini-2.5-pro", + specialModelManager: true, + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginGeminiCli } = await import("./oauth/google-gemini-cli"); + return loginGeminiCli(cb); + }, + refreshToken: async (credentials: OAuthCredentials) => { + if (!credentials.projectId) { + throw new Error("Google Cloud credentials missing projectId"); + } + const { refreshGoogleCloudToken } = await import("./oauth/google-gemini-cli"); + return refreshGoogleCloudToken(credentials.refresh, credentials.projectId); + }, + callbackPort: 8085, + pasteCodeFlow: true, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/google-vertex.ts b/packages/ai/src/registry/google-vertex.ts new file mode 100644 index 000000000..9dc6ca9b3 --- /dev/null +++ b/packages/ai/src/registry/google-vertex.ts @@ -0,0 +1,42 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { $env } from "@oh-my-pi/pi-utils"; +import { googleVertexModelManagerOptions } from "../provider-models/google"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +let cachedVertexAdcCredentialsExists: boolean | null = null; + +function hasVertexAdcCredentials(): boolean { + if (cachedVertexAdcCredentialsExists === null) { + const gacPath = $env.GOOGLE_APPLICATION_CREDENTIALS; + if (gacPath) { + cachedVertexAdcCredentialsExists = fs.existsSync(gacPath); + } else { + cachedVertexAdcCredentialsExists = fs.existsSync( + path.join(os.homedir(), ".config", "gcloud", "application_default_credentials.json"), + ); + } + } + return cachedVertexAdcCredentialsExists; +} + +export const googleVertexProvider = { + id: "google-vertex", + name: "Google Vertex AI", + defaultModel: "gemini-3-pro-preview", + createModelManagerOptions: (config: ModelManagerConfig) => googleVertexModelManagerOptions(config), + allowUnauthenticated: true, + // Vertex AI supports either GOOGLE_CLOUD_API_KEY or Application Default Credentials. + envKeys: () => { + if ($env.GOOGLE_CLOUD_API_KEY) { + return $env.GOOGLE_CLOUD_API_KEY; + } + const hasCredentials = hasVertexAdcCredentials(); + const hasProject = !!($env.GOOGLE_CLOUD_PROJECT || $env.GCP_PROJECT || $env.GCLOUD_PROJECT); + const hasLocation = !!($env.GOOGLE_VERTEX_LOCATION || $env.GOOGLE_CLOUD_LOCATION || $env.VERTEX_LOCATION); + if (hasCredentials && hasProject && hasLocation) { + return ""; + } + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/google.ts b/packages/ai/src/registry/google.ts new file mode 100644 index 000000000..2f4bfca49 --- /dev/null +++ b/packages/ai/src/registry/google.ts @@ -0,0 +1,10 @@ +import { googleModelManagerOptions } from "../provider-models/google"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const googleProvider = { + id: "google", + name: "Google Gemini", + defaultModel: "gemini-2.5-pro", + createModelManagerOptions: (config: ModelManagerConfig) => googleModelManagerOptions(config), + envKeys: "GEMINI_API_KEY", +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/groq.ts b/packages/ai/src/registry/groq.ts new file mode 100644 index 000000000..7c636d6f7 --- /dev/null +++ b/packages/ai/src/registry/groq.ts @@ -0,0 +1,10 @@ +import { groqModelManagerOptions } from "../provider-models/openai-compat"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const groqProvider = { + id: "groq", + name: "Groq", + defaultModel: "openai/gpt-oss-120b", + createModelManagerOptions: (config: ModelManagerConfig) => groqModelManagerOptions(config), + envKeys: "GROQ_API_KEY", +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/huggingface.ts b/packages/ai/src/registry/huggingface.ts similarity index 61% rename from packages/ai/src/utils/oauth/huggingface.ts rename to packages/ai/src/registry/huggingface.ts index ca5553f0f..d5d0f4115 100644 --- a/packages/ai/src/utils/oauth/huggingface.ts +++ b/packages/ai/src/registry/huggingface.ts @@ -1,29 +1,14 @@ -/** - * Hugging Face Inference login flow. - * - * Hugging Face Inference Providers expose an OpenAI-compatible endpoint via - * https://router.huggingface.co/v1. - * - * This is an API key flow: - * 1. Open browser to Hugging Face token settings - * 2. User creates/copies a token with Inference Providers permission - * 3. User pastes the token into the CLI - */ - +import { $pickenv } from "@oh-my-pi/pi-utils"; +import { huggingfaceModelManagerOptions } from "../provider-models/openai-compat"; import { validateOpenAICompatibleApiKey } from "./api-key-validation"; -import type { OAuthController } from "./types"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const AUTH_URL = "https://huggingface.co/settings/tokens/new?ownUserPermissions=inference.serverless.write&tokenType=fineGrained"; const API_BASE_URL = "https://router.huggingface.co/v1"; const VALIDATION_MODEL = "openai/gpt-oss-120b"; -/** - * Login to Hugging Face Inference Providers. - * - * Opens browser to token settings, prompts user to paste their API key. - * Returns the API key directly (not OAuthCredentials - this isn't OAuth). - */ export async function loginHuggingface(options: OAuthController): Promise { if (!options.onPrompt) { throw new Error("Hugging Face login requires onPrompt callback"); @@ -60,3 +45,13 @@ export async function loginHuggingface(options: OAuthController): Promise huggingfaceModelManagerOptions(config), + catalogDiscovery: { label: "Hugging Face", envVars: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"] }, + envKeys: () => $pickenv("HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"), + login: (cb: OAuthLoginCallbacks) => loginHuggingface(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/index.ts b/packages/ai/src/registry/index.ts new file mode 100644 index 000000000..ba0f057af --- /dev/null +++ b/packages/ai/src/registry/index.ts @@ -0,0 +1,4 @@ +export * from "./derived"; +export * from "./oauth"; +export * from "./registry"; +export * from "./types"; diff --git a/packages/ai/src/utils/oauth/kagi.ts b/packages/ai/src/registry/kagi.ts similarity index 74% rename from packages/ai/src/utils/oauth/kagi.ts rename to packages/ai/src/registry/kagi.ts index 3833c43ad..e856a6b62 100644 --- a/packages/ai/src/utils/oauth/kagi.ts +++ b/packages/ai/src/registry/kagi.ts @@ -1,14 +1,5 @@ -/** - * Kagi login flow. - * - * Kagi web search uses an API key from the account settings page. - * This is an API key flow: - * 1. Open browser to Kagi API settings - * 2. User copies API key - * 3. User pastes key into CLI - */ - -import type { OAuthController } from "./types"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ProviderDefinition } from "./types"; const AUTH_URL = "https://kagi.com/settings/api"; @@ -45,3 +36,10 @@ export async function loginKagi(options: OAuthController): Promise { return trimmed; } + +export const kagiProvider = { + id: "kagi", + name: "Kagi", + envKeys: "KAGI_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginKagi(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/kilo.ts b/packages/ai/src/registry/kilo.ts similarity index 80% rename from packages/ai/src/utils/oauth/kilo.ts rename to packages/ai/src/registry/kilo.ts index 6b0293456..2c593554a 100644 --- a/packages/ai/src/utils/oauth/kilo.ts +++ b/packages/ai/src/registry/kilo.ts @@ -1,4 +1,6 @@ -import type { OAuthController, OAuthCredentials } from "./types"; +import { kiloModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthController, OAuthCredentials } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const KILO_DEVICE_AUTH_BASE_URL = "https://api.kilo.ai/api/device-auth"; const POLL_INTERVAL_MS = 5000; @@ -15,9 +17,6 @@ interface KiloDeviceAuthPollResponse { token?: string; } -/** - * Login with Kilo Gateway OAuth (device code flow). - */ export async function loginKilo(callbacks: OAuthController): Promise { const initiateResponse = await fetch(`${KILO_DEVICE_AUTH_BASE_URL}/codes`, { method: "POST", @@ -85,3 +84,13 @@ export async function loginKilo(callbacks: OAuthController): Promise kiloModelManagerOptions(config), + catalogDiscovery: { label: "Kilo Gateway", envVars: ["KILO_API_KEY"], allowUnauthenticated: true }, + envKeys: "KILO_API_KEY", + login: loginKilo, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/kimi-code.ts b/packages/ai/src/registry/kimi-code.ts new file mode 100644 index 000000000..aedb23beb --- /dev/null +++ b/packages/ai/src/registry/kimi-code.ts @@ -0,0 +1,21 @@ +import { kimiCodeModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const kimiCodeProvider = { + id: "kimi-code", + name: "Kimi Code", + defaultModel: "kimi-k2.5", + createModelManagerOptions: (config: ModelManagerConfig) => kimiCodeModelManagerOptions(config), + catalogDiscovery: { label: "Kimi Code", envVars: ["KIMI_API_KEY"] }, + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginKimi } = await import("./oauth/kimi"); + return loginKimi(cb); + }, + refreshToken: async (credentials: OAuthCredentials) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { refreshKimiToken } = await import("./oauth/kimi"); + return refreshKimiToken(credentials.refresh); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/litellm.ts b/packages/ai/src/registry/litellm.ts similarity index 58% rename from packages/ai/src/utils/oauth/litellm.ts rename to packages/ai/src/registry/litellm.ts index 4ddd6542e..a84072092 100644 --- a/packages/ai/src/utils/oauth/litellm.ts +++ b/packages/ai/src/registry/litellm.ts @@ -1,15 +1,6 @@ -/** - * LiteLLM login flow. - * - * LiteLLM is an OpenAI-compatible proxy that routes requests to many upstream providers. - * - * This is not OAuth - it's a simple API key flow: - * 1. Open browser to LiteLLM docs/dashboard - * 2. User copies their LiteLLM API key - * 3. User pastes the API key into the CLI - */ - -import type { OAuthController } from "./types"; +import { litellmModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const AUTH_URL = "https://docs.litellm.ai/docs/proxy/deploy"; @@ -45,3 +36,13 @@ export async function loginLiteLLM(options: OAuthController): Promise { return trimmed; } + +export const litellmProvider = { + id: "litellm", + name: "LiteLLM", + defaultModel: "claude-opus-4-6", + createModelManagerOptions: (config: ModelManagerConfig) => litellmModelManagerOptions(config), + catalogDiscovery: { label: "LiteLLM", envVars: ["LITELLM_API_KEY"], allowUnauthenticated: true }, + envKeys: "LITELLM_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginLiteLLM(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/lm-studio.ts b/packages/ai/src/registry/lm-studio.ts new file mode 100644 index 000000000..869560b59 --- /dev/null +++ b/packages/ai/src/registry/lm-studio.ts @@ -0,0 +1,35 @@ +import { lmStudioModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +const PROVIDER_ID = "lm-studio"; +export const DEFAULT_LOCAL_TOKEN = "lm-studio-local"; + +export async function loginLmStudio(options: OAuthController): Promise { + if (!options.onPrompt) { + throw new Error(`${PROVIDER_ID} login requires onPrompt callback`); + } + + const apiKey = await options.onPrompt({ + message: "Optional: Paste LM Studio API key (to customize endpoint URL, set LM_STUDIO_BASE_URL env var)", + placeholder: DEFAULT_LOCAL_TOKEN, + allowEmpty: true, + }); + + if (options.signal?.aborted) { + throw new Error("Login cancelled"); + } + + const trimmed = apiKey.trim(); + return trimmed || DEFAULT_LOCAL_TOKEN; +} + +export const lmStudioProvider = { + id: "lm-studio", + name: "LM Studio (Local OpenAI-compatible)", + defaultModel: "llama-3-8b", + createModelManagerOptions: (config: ModelManagerConfig) => lmStudioModelManagerOptions(config), + allowUnauthenticated: true, + envKeys: "LM_STUDIO_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginLmStudio(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/minimax-code-cn.ts b/packages/ai/src/registry/minimax-code-cn.ts new file mode 100644 index 000000000..c6b1ccd85 --- /dev/null +++ b/packages/ai/src/registry/minimax-code-cn.ts @@ -0,0 +1,14 @@ +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ProviderDefinition } from "./types"; + +export const minimaxCodeCnProvider = { + id: "minimax-code-cn", + name: "MiniMax Coding Plan (China)", + defaultModel: "MiniMax-M2.5", + envKeys: "MINIMAX_CODE_CN_API_KEY", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginMiniMaxCodeCn } = await import("./oauth/minimax-code"); + return loginMiniMaxCodeCn(cb); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/minimax-code.ts b/packages/ai/src/registry/minimax-code.ts new file mode 100644 index 000000000..a9733d32f --- /dev/null +++ b/packages/ai/src/registry/minimax-code.ts @@ -0,0 +1,14 @@ +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ProviderDefinition } from "./types"; + +export const minimaxCodeProvider = { + id: "minimax-code", + name: "MiniMax Coding Plan (International)", + defaultModel: "MiniMax-M2.5", + envKeys: "MINIMAX_CODE_API_KEY", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginMiniMaxCode } = await import("./oauth/minimax-code"); + return loginMiniMaxCode(cb); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/minimax.ts b/packages/ai/src/registry/minimax.ts new file mode 100644 index 000000000..c6ff9ae1f --- /dev/null +++ b/packages/ai/src/registry/minimax.ts @@ -0,0 +1,8 @@ +import type { ProviderDefinition } from "./types"; + +export const minimaxProvider = { + id: "minimax", + name: "MiniMax", + defaultModel: "MiniMax-M2.5", + envKeys: "MINIMAX_API_KEY", +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/mistral.ts b/packages/ai/src/registry/mistral.ts new file mode 100644 index 000000000..b9bfc634d --- /dev/null +++ b/packages/ai/src/registry/mistral.ts @@ -0,0 +1,10 @@ +import { mistralModelManagerOptions } from "../provider-models/openai-compat"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const mistralProvider = { + id: "mistral", + name: "Mistral", + defaultModel: "devstral-medium-latest", + createModelManagerOptions: (config: ModelManagerConfig) => mistralModelManagerOptions(config), + envKeys: "MISTRAL_API_KEY", +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/moonshot.ts b/packages/ai/src/registry/moonshot.ts new file mode 100644 index 000000000..52fa5dfea --- /dev/null +++ b/packages/ai/src/registry/moonshot.ts @@ -0,0 +1,27 @@ +import { moonshotModelManagerOptions } from "../provider-models/openai-compat"; +import { createApiKeyLogin } from "./api-key-login"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const loginMoonshot = createApiKeyLogin({ + providerLabel: "Moonshot", + authUrl: "https://platform.moonshot.ai/console/api-keys", + instructions: "Copy your API key from the Moonshot dashboard", + promptMessage: "Paste your Moonshot API key", + placeholder: "sk-...", + validation: { + kind: "models-endpoint", + provider: "moonshot", + modelsUrl: "https://api.moonshot.ai/v1/models", + }, +}); + +export const moonshotProvider = { + id: "moonshot", + name: "Moonshot (Kimi API)", + defaultModel: "kimi-k2.5", + createModelManagerOptions: (config: ModelManagerConfig) => moonshotModelManagerOptions(config), + catalogDiscovery: { label: "Moonshot", envVars: ["MOONSHOT_API_KEY"] }, + envKeys: "MOONSHOT_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginMoonshot(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/nanogpt.ts b/packages/ai/src/registry/nanogpt.ts new file mode 100644 index 000000000..a04b8da99 --- /dev/null +++ b/packages/ai/src/registry/nanogpt.ts @@ -0,0 +1,27 @@ +import { nanoGptModelManagerOptions } from "../provider-models/openai-compat"; +import { createApiKeyLogin } from "./api-key-login"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const loginNanoGPT = createApiKeyLogin({ + providerLabel: "NanoGPT", + authUrl: "https://nano-gpt.com/api", + instructions: "Create or copy your NanoGPT API key", + promptMessage: "Paste your NanoGPT API key", + placeholder: "sk-...", + validation: { + kind: "models-endpoint", + provider: "NanoGPT", + modelsUrl: "https://nano-gpt.com/api/v1/models", + }, +}); + +export const nanogptProvider = { + id: "nanogpt", + name: "NanoGPT", + defaultModel: "openai/gpt-5.4", + createModelManagerOptions: (config: ModelManagerConfig) => nanoGptModelManagerOptions(config), + catalogDiscovery: { label: "NanoGPT", envVars: ["NANO_GPT_API_KEY"] }, + envKeys: "NANO_GPT_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginNanoGPT(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/nvidia.ts b/packages/ai/src/registry/nvidia.ts similarity index 70% rename from packages/ai/src/utils/oauth/nvidia.ts rename to packages/ai/src/registry/nvidia.ts index 50fb10800..35a90af44 100644 --- a/packages/ai/src/utils/oauth/nvidia.ts +++ b/packages/ai/src/registry/nvidia.ts @@ -1,28 +1,13 @@ -/** - * NVIDIA login flow. - * - * NVIDIA provides OpenAI-compatible models via https://integrate.api.nvidia.com/v1. - * - * This is not OAuth - it's a simple API key flow: - * 1. Open browser to NVIDIA NGC catalog - * 2. User copies their API key - * 3. User pastes the API key into the CLI - */ - +import { nvidiaModelManagerOptions } from "../provider-models/openai-compat"; import { validateOpenAICompatibleApiKey } from "./api-key-validation"; -import type { OAuthController } from "./types"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const AUTH_URL = "https://org.ngc.nvidia.com/setup/personal-keys"; const API_BASE_URL = "https://integrate.api.nvidia.com/v1"; const VALIDATION_MODEL = "nvidia/llama-3.1-nemotron-70b-instruct"; const PROVIDER_ID = "nvidia"; -/** - * Login to NVIDIA. - * - * Opens browser to NVIDIA dashboard, prompts user to paste their API key. - * Returns the API key directly (not OAuthCredentials - this isn't OAuth). - */ export async function loginNvidia(options: OAuthController): Promise { if (!options.onPrompt) { throw new Error("NVIDIA login requires onPrompt callback"); @@ -68,3 +53,13 @@ export async function loginNvidia(options: OAuthController): Promise { return trimmed; } + +export const nvidiaProvider = { + id: "nvidia", + name: "NVIDIA", + defaultModel: "nvidia/llama-3.1-nemotron-70b-instruct", + createModelManagerOptions: (config: ModelManagerConfig) => nvidiaModelManagerOptions(config), + catalogDiscovery: { label: "NVIDIA", envVars: ["NVIDIA_API_KEY"] }, + envKeys: "NVIDIA_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginNvidia(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/__tests__/xai-oauth.test.ts b/packages/ai/src/registry/oauth/__tests__/xai-oauth.test.ts similarity index 100% rename from packages/ai/src/utils/oauth/__tests__/xai-oauth.test.ts rename to packages/ai/src/registry/oauth/__tests__/xai-oauth.test.ts diff --git a/packages/ai/src/utils/oauth/anthropic.ts b/packages/ai/src/registry/oauth/anthropic.ts similarity index 100% rename from packages/ai/src/utils/oauth/anthropic.ts rename to packages/ai/src/registry/oauth/anthropic.ts diff --git a/packages/ai/src/utils/oauth/callback-server.ts b/packages/ai/src/registry/oauth/callback-server.ts similarity index 100% rename from packages/ai/src/utils/oauth/callback-server.ts rename to packages/ai/src/registry/oauth/callback-server.ts diff --git a/packages/ai/src/utils/oauth/cursor.ts b/packages/ai/src/registry/oauth/cursor.ts similarity index 100% rename from packages/ai/src/utils/oauth/cursor.ts rename to packages/ai/src/registry/oauth/cursor.ts diff --git a/packages/ai/src/utils/oauth/github-copilot.ts b/packages/ai/src/registry/oauth/github-copilot.ts similarity index 100% rename from packages/ai/src/utils/oauth/github-copilot.ts rename to packages/ai/src/registry/oauth/github-copilot.ts diff --git a/packages/ai/src/utils/oauth/gitlab-duo.ts b/packages/ai/src/registry/oauth/gitlab-duo.ts similarity index 100% rename from packages/ai/src/utils/oauth/gitlab-duo.ts rename to packages/ai/src/registry/oauth/gitlab-duo.ts diff --git a/packages/ai/src/utils/oauth/google-antigravity.ts b/packages/ai/src/registry/oauth/google-antigravity.ts similarity index 100% rename from packages/ai/src/utils/oauth/google-antigravity.ts rename to packages/ai/src/registry/oauth/google-antigravity.ts diff --git a/packages/ai/src/utils/oauth/google-gemini-cli.ts b/packages/ai/src/registry/oauth/google-gemini-cli.ts similarity index 100% rename from packages/ai/src/utils/oauth/google-gemini-cli.ts rename to packages/ai/src/registry/oauth/google-gemini-cli.ts diff --git a/packages/ai/src/utils/oauth/google-oauth-shared.ts b/packages/ai/src/registry/oauth/google-oauth-shared.ts similarity index 100% rename from packages/ai/src/utils/oauth/google-oauth-shared.ts rename to packages/ai/src/registry/oauth/google-oauth-shared.ts diff --git a/packages/ai/src/registry/oauth/index.ts b/packages/ai/src/registry/oauth/index.ts new file mode 100644 index 000000000..a5d2e581d --- /dev/null +++ b/packages/ai/src/registry/oauth/index.ts @@ -0,0 +1,164 @@ +// ============================================================================ +// High-level API +// ============================================================================ + +import { getProviderDefinition, PROVIDER_REGISTRY } from "../registry"; +import type { + OAuthCredentials, + OAuthProvider, + OAuthProviderId, + OAuthProviderInfo, + OAuthProviderInterface, +} from "./types"; + +export type * from "./types"; + +const builtInOAuthProviders: OAuthProviderInfo[] = PROVIDER_REGISTRY.filter( + provider => provider.login && provider.showInLoginList !== false, +).map(provider => ({ + id: provider.id, + name: provider.name, + available: provider.available ?? true, +})); + +const customOAuthProviders = new Map(); + +/** + * Register a custom OAuth provider. + */ +export function registerOAuthProvider(provider: OAuthProviderInterface): void { + customOAuthProviders.set(provider.id, provider); +} + +/** + * Get a custom OAuth provider by ID. + */ +export function getOAuthProvider(id: OAuthProviderId): OAuthProviderInterface | undefined { + return customOAuthProviders.get(id); +} + +/** + * Remove all custom OAuth providers registered by a source. + */ +export function unregisterOAuthProviders(sourceId: string): void { + for (const [id, provider] of customOAuthProviders.entries()) { + if (provider.sourceId === sourceId) { + customOAuthProviders.delete(id); + } + } +} + +/** + * Refresh token for any OAuth provider. + * Saves the new credentials and returns the new access token. + */ +export async function refreshOAuthToken( + provider: OAuthProvider, + credentials: OAuthCredentials, +): Promise { + if (!credentials) { + throw new Error(`No OAuth credentials found for ${provider}`); + } + const def = getProviderDefinition(provider); + if (!def?.login) { + throw new Error(`Unknown OAuth provider: ${provider}`); + } + // Providers without a real refresher (static bearer tokens / API keys that + // don't expire) return the credentials unchanged. + return def.refreshToken ? def.refreshToken(credentials) : credentials; +} +function getPerplexityJwtExpiryMs(token: string): number | undefined { + const parts = token.split("."); + if (parts.length !== 3) return undefined; + const payload = parts[1]; + if (!payload) return undefined; + try { + const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { exp?: unknown }; + if (typeof decoded.exp !== "number" || !Number.isFinite(decoded.exp)) return undefined; + return decoded.exp * 1000 - 5 * 60_000; + } catch { + return undefined; + } +} + +/** + * Build API-key bytes for a provider from an already-fresh OAuth credential. + * + * Refresh is owned by AuthStorage. This helper deliberately refuses expired + * credentials so it cannot POST broker redaction sentinels to upstream token + * endpoints as a side channel. + * + * For providers that need credential metadata at request time, returns + * JSON-encoded credentials plus expiry metadata for diagnostics/edge guards. + * @returns API key string, or null if no credentials + * @throws Error if the credential is expired and must be refreshed upstream + */ +export async function getOAuthApiKey( + provider: OAuthProvider, + credentials: Record, +): Promise<{ newCredentials: OAuthCredentials; apiKey: string } | null> { + let creds = credentials[provider]; + if (!creds) { + return null; + } + + if (provider === "perplexity") { + // Perplexity JWTs usually omit `exp` (server-side sessions). Trust the JWT + // claim when present; otherwise treat the credential as non-expiring rather + // than honoring a stale stored `expires` (older logins wrote loginTime+1h). + const NEVER_EXPIRES = 8.64e15; + const normalizedExpires = + creds.expires > 0 && creds.expires < 10_000_000_000 ? creds.expires * 1000 : creds.expires; + const jwtExpiry = getPerplexityJwtExpiryMs(creds.access); + const expires = jwtExpiry ?? Math.max(normalizedExpires, NEVER_EXPIRES); + if (expires !== creds.expires) { + creds = { ...creds, expires }; + } + } + // Refresh is the sole responsibility of `AuthStorage` (which calls + // `refreshOAuthToken` directly with broker-aware single-flighting). If we + // reach here with an expired credential, the outer pipeline failed to + // refresh before this call OR the refresh slot is the broker sentinel — + // either way, posting the credential to a provider endpoint would only + // trigger a `__remote__`-against-real-provider failure that gets classified + // as `invalid_grant` and disables the row. Refuse loudly instead. + if (Date.now() >= creds.expires) { + if (provider === "perplexity") { + const jwtExpiry = getPerplexityJwtExpiryMs(creds.access); + if (jwtExpiry && Date.now() < jwtExpiry) { + const fallbackCredentials = { ...creds, expires: jwtExpiry }; + return { newCredentials: fallbackCredentials, apiKey: fallbackCredentials.access }; + } + } + throw new Error( + `OAuth credential for ${provider} is expired and must be refreshed via AuthStorage before getOAuthApiKey is called`, + ); + } + // For providers that need request-time credential metadata, return JSON. + const needsStructuredApiKey = + provider === "github-copilot" || provider === "google-gemini-cli" || provider === "google-antigravity"; + const apiKey = needsStructuredApiKey + ? JSON.stringify({ + token: creds.access, + enterpriseUrl: creds.enterpriseUrl, + projectId: creds.projectId, + refreshToken: creds.refresh, + expiresAt: creds.expires, + email: creds.email, + accountId: creds.accountId, + }) + : creds.access; + return { newCredentials: creds, apiKey }; +} + +/** + * Get list of OAuth providers. + */ +export function getOAuthProviders(): OAuthProviderInfo[] { + const customProviders = Array.from(customOAuthProviders.values(), provider => ({ + id: provider.id, + name: provider.name, + available: true, + })); + return [...builtInOAuthProviders, ...customProviders]; +} diff --git a/packages/ai/src/utils/oauth/kimi.ts b/packages/ai/src/registry/oauth/kimi.ts similarity index 100% rename from packages/ai/src/utils/oauth/kimi.ts rename to packages/ai/src/registry/oauth/kimi.ts diff --git a/packages/ai/src/utils/oauth/minimax-code.ts b/packages/ai/src/registry/oauth/minimax-code.ts similarity index 97% rename from packages/ai/src/utils/oauth/minimax-code.ts rename to packages/ai/src/registry/oauth/minimax-code.ts index 0000ed31e..23e7db2f7 100644 --- a/packages/ai/src/utils/oauth/minimax-code.ts +++ b/packages/ai/src/registry/oauth/minimax-code.ts @@ -13,7 +13,7 @@ * China: https://api.minimaxi.com/v1 */ -import { validateOpenAICompatibleApiKey } from "./api-key-validation"; +import { validateOpenAICompatibleApiKey } from "../api-key-validation"; import type { OAuthController } from "./types"; const AUTH_URL_INTL = "https://platform.minimax.io/subscribe/coding-plan"; diff --git a/packages/ai/src/utils/oauth/oauth.html b/packages/ai/src/registry/oauth/oauth.html similarity index 100% rename from packages/ai/src/utils/oauth/oauth.html rename to packages/ai/src/registry/oauth/oauth.html diff --git a/packages/ai/src/utils/oauth/openai-codex.ts b/packages/ai/src/registry/oauth/openai-codex.ts similarity index 100% rename from packages/ai/src/utils/oauth/openai-codex.ts rename to packages/ai/src/registry/oauth/openai-codex.ts diff --git a/packages/ai/src/utils/oauth/opencode.ts b/packages/ai/src/registry/oauth/opencode.ts similarity index 100% rename from packages/ai/src/utils/oauth/opencode.ts rename to packages/ai/src/registry/oauth/opencode.ts diff --git a/packages/ai/src/utils/oauth/perplexity.ts b/packages/ai/src/registry/oauth/perplexity.ts similarity index 100% rename from packages/ai/src/utils/oauth/perplexity.ts rename to packages/ai/src/registry/oauth/perplexity.ts diff --git a/packages/ai/src/utils/oauth/pkce.ts b/packages/ai/src/registry/oauth/pkce.ts similarity index 100% rename from packages/ai/src/utils/oauth/pkce.ts rename to packages/ai/src/registry/oauth/pkce.ts diff --git a/packages/ai/src/utils/oauth/types.ts b/packages/ai/src/registry/oauth/types.ts similarity index 58% rename from packages/ai/src/utils/oauth/types.ts rename to packages/ai/src/registry/oauth/types.ts index 4fd9a887b..e7006c1cb 100644 --- a/packages/ai/src/utils/oauth/types.ts +++ b/packages/ai/src/registry/oauth/types.ts @@ -1,3 +1,5 @@ +import type { OAuthProviderUnion } from "../registry"; + export type OAuthCredentials = { refresh: string; access: string; @@ -8,57 +10,7 @@ export type OAuthCredentials = { accountId?: string; }; -export type OAuthProvider = - | "alibaba-coding-plan" - | "anthropic" - | "cerebras" - | "cloudflare-ai-gateway" - | "cursor" - | "deepseek" - | "fireworks" - | "firepass" - | "github-copilot" - | "google-gemini-cli" - | "google-antigravity" - | "gitlab-duo" - | "huggingface" - | "kimi-code" - | "kilo" - | "kagi" - | "litellm" - | "lm-studio" - | "minimax-code" - | "minimax-code-cn" - | "moonshot" - | "nvidia" - | "nanogpt" - | "ollama" - | "ollama-cloud" - | "openai-codex" - | "openai-codex-device" - | "opencode-go" - | "openrouter" - | "opencode-zen" - | "parallel" - | "perplexity" - | "qianfan" - | "qwen-portal" - | "synthetic" - | "tavily" - | "together" - | "venice" - | "vercel-ai-gateway" - | "wafer-pass" - | "wafer-serverless" - | "vllm" - | "xai-oauth" - | "xiaomi" - | "xiaomi-token-plan-sgp" - | "xiaomi-token-plan-ams" - | "xiaomi-token-plan-cn" - | "zenmux" - | "zai" - | "zhipu-coding-plan"; +export type OAuthProvider = OAuthProviderUnion; export type OAuthProviderId = OAuthProvider | (string & {}); @@ -99,4 +51,6 @@ export interface OAuthProviderInterface { login(callbacks: OAuthLoginCallbacks): Promise; refreshToken?(credentials: OAuthCredentials): Promise; getApiKey?(credentials: OAuthCredentials): string; + /** Store resulting OAuth credentials under a different provider id. */ + readonly storeCredentialsAs?: string; } diff --git a/packages/ai/src/utils/oauth/wafer.ts b/packages/ai/src/registry/oauth/wafer.ts similarity index 97% rename from packages/ai/src/utils/oauth/wafer.ts rename to packages/ai/src/registry/oauth/wafer.ts index 490ceada6..d9d74a557 100644 --- a/packages/ai/src/utils/oauth/wafer.ts +++ b/packages/ai/src/registry/oauth/wafer.ts @@ -18,7 +18,7 @@ * Validation uses the shared `/v1/models` endpoint, which works for both * tiers and is cheap (no token spend). */ -import { createApiKeyLogin } from "./api-key-login"; +import { createApiKeyLogin } from "../api-key-login"; const WAFER_AUTH_URL = "https://wafer.ai/dashboard"; const WAFER_MODELS_URL = "https://pass.wafer.ai/v1/models"; diff --git a/packages/ai/src/utils/oauth/xai-oauth.ts b/packages/ai/src/registry/oauth/xai-oauth.ts similarity index 100% rename from packages/ai/src/utils/oauth/xai-oauth.ts rename to packages/ai/src/registry/oauth/xai-oauth.ts diff --git a/packages/ai/src/utils/oauth/xiaomi.ts b/packages/ai/src/registry/oauth/xiaomi.ts similarity index 100% rename from packages/ai/src/utils/oauth/xiaomi.ts rename to packages/ai/src/registry/oauth/xiaomi.ts diff --git a/packages/ai/src/utils/oauth/ollama-cloud.ts b/packages/ai/src/registry/ollama-cloud.ts similarity index 53% rename from packages/ai/src/utils/oauth/ollama-cloud.ts rename to packages/ai/src/registry/ollama-cloud.ts index 00caa7d31..322c042b1 100644 --- a/packages/ai/src/utils/oauth/ollama-cloud.ts +++ b/packages/ai/src/registry/ollama-cloud.ts @@ -1,4 +1,6 @@ -import type { OAuthController } from "./types"; +import { ollamaCloudModelManagerOptions } from "../provider-models/ollama"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const OLLAMA_CLOUD_KEYS_URL = "https://ollama.com/settings/keys"; @@ -26,3 +28,13 @@ export async function loginOllamaCloud(options: OAuthController): Promise ollamaCloudModelManagerOptions(config), + catalogDiscovery: { label: "Ollama Cloud", envVars: ["OLLAMA_CLOUD_API_KEY"], oauthProvider: "ollama-cloud" }, + envKeys: "OLLAMA_CLOUD_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginOllamaCloud(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/ollama.ts b/packages/ai/src/registry/ollama.ts similarity index 60% rename from packages/ai/src/utils/oauth/ollama.ts rename to packages/ai/src/registry/ollama.ts index 868d00efa..375452e51 100644 --- a/packages/ai/src/utils/oauth/ollama.ts +++ b/packages/ai/src/registry/ollama.ts @@ -1,16 +1,6 @@ -/** - * Ollama login flow. - * - * Ollama is typically used locally without authentication, but some hosted - * deployments require a bearer token/API key. - * - * This flow is API-key based (not OAuth): - * 1. Optionally open Ollama docs - * 2. Prompt user for API key/token (optional) - * 3. Persist key only when provided - */ - -import type { OAuthController } from "./types"; +import { ollamaModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthController } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const OLLAMA_DOCS_URL = "https://github.com/ollama/ollama/blob/main/docs/api.md"; @@ -45,3 +35,13 @@ export async function loginOllama(options: OAuthController): Promise { return apiKey.trim(); } + +export const ollamaProvider = { + id: "ollama", + name: "Ollama (Local OpenAI-compatible)", + defaultModel: "gpt-oss:20b", + createModelManagerOptions: (config: ModelManagerConfig) => ollamaModelManagerOptions(config), + allowUnauthenticated: true, + login: loginOllama, + envKeys: "OLLAMA_API_KEY", +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/openai-codex-device.ts b/packages/ai/src/registry/openai-codex-device.ts new file mode 100644 index 000000000..95f9b3867 --- /dev/null +++ b/packages/ai/src/registry/openai-codex-device.ts @@ -0,0 +1,18 @@ +import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; +import type { ProviderDefinition } from "./types"; + +export const openaiCodexDeviceProvider = { + id: "openai-codex-device", + name: "ChatGPT Plus/Pro (Codex, headless/device)", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginOpenAICodexDevice } = await import("./oauth/openai-codex"); + return loginOpenAICodexDevice(cb); + }, + refreshToken: async (credentials: OAuthCredentials) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { refreshOpenAICodexToken } = await import("./oauth/openai-codex"); + return refreshOpenAICodexToken(credentials.refresh); + }, + storeCredentialsAs: "openai-codex", +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/openai-codex.ts b/packages/ai/src/registry/openai-codex.ts new file mode 100644 index 000000000..3957bf788 --- /dev/null +++ b/packages/ai/src/registry/openai-codex.ts @@ -0,0 +1,22 @@ +import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; +import type { ProviderDefinition } from "./types"; + +export const openaiCodexProvider = { + id: "openai-codex", + name: "ChatGPT Plus/Pro (Codex Subscription)", + defaultModel: "gpt-5.4", + specialModelManager: true, + envKeys: "OPENAI_CODEX_OAUTH_TOKEN", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginOpenAICodex } = await import("./oauth/openai-codex"); + return loginOpenAICodex(cb); + }, + refreshToken: async (credentials: OAuthCredentials) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { refreshOpenAICodexToken } = await import("./oauth/openai-codex"); + return refreshOpenAICodexToken(credentials.refresh); + }, + callbackPort: 1455, + pasteCodeFlow: true, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/openai.ts b/packages/ai/src/registry/openai.ts new file mode 100644 index 000000000..1fe6b3c33 --- /dev/null +++ b/packages/ai/src/registry/openai.ts @@ -0,0 +1,10 @@ +import { openaiModelManagerOptions } from "../provider-models/openai-compat"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const openaiProvider = { + id: "openai", + name: "OpenAI", + defaultModel: "gpt-5.4", + createModelManagerOptions: (config: ModelManagerConfig) => openaiModelManagerOptions(config), + envKeys: "OPENAI_API_KEY", +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/opencode-go.ts b/packages/ai/src/registry/opencode-go.ts new file mode 100644 index 000000000..b9dfdaf92 --- /dev/null +++ b/packages/ai/src/registry/opencode-go.ts @@ -0,0 +1,16 @@ +import { opencodeGoModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const opencodeGoProvider = { + id: "opencode-go", + name: "OpenCode Go", + defaultModel: "kimi-k2.5", + createModelManagerOptions: (config: ModelManagerConfig) => opencodeGoModelManagerOptions(config), + envKeys: "OPENCODE_API_KEY", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginOpenCode } = await import("./oauth/opencode"); + return loginOpenCode(cb); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/opencode-zen.ts b/packages/ai/src/registry/opencode-zen.ts new file mode 100644 index 000000000..87e652e13 --- /dev/null +++ b/packages/ai/src/registry/opencode-zen.ts @@ -0,0 +1,16 @@ +import { opencodeZenModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const opencodeZenProvider = { + id: "opencode-zen", + name: "OpenCode Zen", + defaultModel: "claude-sonnet-4-6", + createModelManagerOptions: (config: ModelManagerConfig) => opencodeZenModelManagerOptions(config), + envKeys: "OPENCODE_API_KEY", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginOpenCode } = await import("./oauth/opencode"); + return loginOpenCode(cb); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/openrouter.ts b/packages/ai/src/registry/openrouter.ts similarity index 52% rename from packages/ai/src/utils/oauth/openrouter.ts rename to packages/ai/src/registry/openrouter.ts index c3b539316..8951c79f7 100644 --- a/packages/ai/src/utils/oauth/openrouter.ts +++ b/packages/ai/src/registry/openrouter.ts @@ -1,11 +1,14 @@ +import { openrouterModelManagerOptions } from "../provider-models/openai-compat"; +import { createApiKeyLogin } from "./api-key-login"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + /** OpenRouter login flow (API key paste, validated via /auth/key). * * `/api/v1/models` is public and returns 200 for any bearer (including bogus), * so it cannot validate auth. `/api/v1/auth/key` is the canonical "who am I" * endpoint — 200 for valid keys, 401 otherwise. */ -import { createApiKeyLogin } from "./api-key-login"; - export const loginOpenRouter = createApiKeyLogin({ providerLabel: "OpenRouter", authUrl: "https://openrouter.ai/keys", @@ -18,3 +21,13 @@ export const loginOpenRouter = createApiKeyLogin({ modelsUrl: "https://openrouter.ai/api/v1/auth/key", }, }); + +export const openrouterProvider = { + id: "openrouter", + name: "OpenRouter", + defaultModel: "openai/gpt-5.4", + createModelManagerOptions: (config: ModelManagerConfig) => openrouterModelManagerOptions(config), + catalogDiscovery: { label: "OpenRouter", envVars: ["OPENROUTER_API_KEY"], allowUnauthenticated: true }, + envKeys: "OPENROUTER_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginOpenRouter(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/parallel.ts b/packages/ai/src/registry/parallel.ts similarity index 71% rename from packages/ai/src/utils/oauth/parallel.ts rename to packages/ai/src/registry/parallel.ts index d2b614e63..ef3e98a9d 100644 --- a/packages/ai/src/utils/oauth/parallel.ts +++ b/packages/ai/src/registry/parallel.ts @@ -1,14 +1,5 @@ -/** - * Parallel login flow. - * - * Parallel uses an API key from the account settings page. - * This is an API key flow: - * 1. Open browser to Parallel API key settings - * 2. User copies API key - * 3. User pastes key into the CLI/TUI - */ - -import type { OAuthController } from "./types"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ProviderDefinition } from "./types"; const AUTH_URL = "https://platform.parallel.ai/settings?tab=api-keys"; @@ -44,3 +35,10 @@ export async function loginParallel(options: OAuthController): Promise { return trimmed; } + +export const parallelProvider = { + id: "parallel", + name: "Parallel", + envKeys: "PARALLEL_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginParallel(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/perplexity.ts b/packages/ai/src/registry/perplexity.ts new file mode 100644 index 000000000..11fd98b03 --- /dev/null +++ b/packages/ai/src/registry/perplexity.ts @@ -0,0 +1,13 @@ +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ProviderDefinition } from "./types"; + +export const perplexityProvider = { + id: "perplexity", + name: "Perplexity (Pro/Max)", + envKeys: "PERPLEXITY_API_KEY", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginPerplexity } = await import("./oauth/perplexity"); + return loginPerplexity(cb); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/qianfan.ts b/packages/ai/src/registry/qianfan.ts similarity index 63% rename from packages/ai/src/utils/oauth/qianfan.ts rename to packages/ai/src/registry/qianfan.ts index 77850477f..7d6a6a73c 100644 --- a/packages/ai/src/utils/oauth/qianfan.ts +++ b/packages/ai/src/registry/qianfan.ts @@ -1,26 +1,12 @@ -/** - * Qianfan login flow. - * - * Qianfan provides an OpenAI-compatible API endpoint. - * Login is API-key based: - * 1. Open browser to Qianfan API key console - * 2. User copies API key - * 3. User pastes key into CLI prompt - */ - +import { qianfanModelManagerOptions } from "../provider-models/openai-compat"; import { validateOpenAICompatibleApiKey } from "./api-key-validation"; -import type { OAuthController } from "./types"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const AUTH_URL = "https://console.bce.baidu.com/qianfan/ais/console/apiKey"; const API_BASE_URL = "https://qianfan.baidubce.com/v2"; const VALIDATION_MODEL = "deepseek-v3.2"; -/** - * Login to Qianfan. - * - * Opens browser to API key page, prompts user to paste their API key. - * Returns the API key directly (not OAuthCredentials - this isn't OAuth). - */ export async function loginQianfan(options: OAuthController): Promise { if (!options.onPrompt) { throw new Error("Qianfan login requires onPrompt callback"); @@ -56,3 +42,13 @@ export async function loginQianfan(options: OAuthController): Promise { return trimmed; } + +export const qianfanProvider = { + id: "qianfan", + name: "Qianfan", + defaultModel: "deepseek-v3.2", + createModelManagerOptions: (config: ModelManagerConfig) => qianfanModelManagerOptions(config), + catalogDiscovery: { label: "Qianfan", envVars: ["QIANFAN_API_KEY"] }, + envKeys: "QIANFAN_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginQianfan(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/qwen-portal.ts b/packages/ai/src/registry/qwen-portal.ts similarity index 56% rename from packages/ai/src/utils/oauth/qwen-portal.ts rename to packages/ai/src/registry/qwen-portal.ts index 5ba843cc4..f398962d9 100644 --- a/packages/ai/src/utils/oauth/qwen-portal.ts +++ b/packages/ai/src/registry/qwen-portal.ts @@ -1,28 +1,13 @@ -/** - * Qwen Portal login flow. - * - * Qwen Portal exposes an OpenAI-compatible endpoint at https://portal.qwen.ai/v1 - * and accepts OAuth bearer tokens or API keys. - * - * This is a token/API-key flow: - * 1. Open Qwen Portal - * 2. Copy either your OAuth token or API key - * 3. Paste it into the CLI - */ - +import { $pickenv } from "@oh-my-pi/pi-utils"; +import { qwenPortalModelManagerOptions } from "../provider-models/openai-compat"; import { validateOpenAICompatibleApiKey } from "./api-key-validation"; -import type { OAuthController } from "./types"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const AUTH_URL = "https://chat.qwen.ai"; const API_BASE_URL = "https://portal.qwen.ai/v1"; const VALIDATION_MODEL = "coder-model"; -/** - * Login to Qwen Portal. - * - * Prompts for either `QWEN_OAUTH_TOKEN` or `QWEN_PORTAL_API_KEY` value. - * Returns the value directly (stored as api_key credential in auth storage). - */ export async function loginQwenPortal(options: OAuthController): Promise { if (!options.onPrompt) { throw new Error("Qwen Portal login requires onPrompt callback"); @@ -58,3 +43,17 @@ export async function loginQwenPortal(options: OAuthController): Promise return trimmed; } + +export const qwenPortalProvider = { + id: "qwen-portal", + name: "Qwen Portal", + defaultModel: "coder-model", + createModelManagerOptions: (config: ModelManagerConfig) => qwenPortalModelManagerOptions(config), + catalogDiscovery: { + label: "Qwen Portal", + envVars: ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"], + oauthProvider: "qwen-portal", + }, + envKeys: () => $pickenv("QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"), + login: (cb: OAuthLoginCallbacks) => loginQwenPortal(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/registry.ts b/packages/ai/src/registry/registry.ts new file mode 100644 index 000000000..43085522c --- /dev/null +++ b/packages/ai/src/registry/registry.ts @@ -0,0 +1,141 @@ +import { alibabaCodingPlanProvider } from "./alibaba-coding-plan"; +import { amazonBedrockProvider } from "./amazon-bedrock"; +import { anthropicProvider } from "./anthropic"; +import { cerebrasProvider } from "./cerebras"; +import { cloudflareAiGatewayProvider } from "./cloudflare-ai-gateway"; +import { cursorProvider } from "./cursor"; +import { deepseekProvider } from "./deepseek"; +import { firepassProvider } from "./firepass"; +import { fireworksProvider } from "./fireworks"; +import { githubCopilotProvider } from "./github-copilot"; +import { gitlabDuoProvider } from "./gitlab-duo"; +import { googleProvider } from "./google"; +import { googleAntigravityProvider } from "./google-antigravity"; +import { googleGeminiCliProvider } from "./google-gemini-cli"; +import { googleVertexProvider } from "./google-vertex"; +import { groqProvider } from "./groq"; +import { huggingfaceProvider } from "./huggingface"; +import { kagiProvider } from "./kagi"; +import { kiloProvider } from "./kilo"; +import { kimiCodeProvider } from "./kimi-code"; +import { litellmProvider } from "./litellm"; +import { lmStudioProvider } from "./lm-studio"; +import { minimaxProvider } from "./minimax"; +import { minimaxCodeProvider } from "./minimax-code"; +import { minimaxCodeCnProvider } from "./minimax-code-cn"; +import { mistralProvider } from "./mistral"; +import { moonshotProvider } from "./moonshot"; +import { nanogptProvider } from "./nanogpt"; +import { nvidiaProvider } from "./nvidia"; +import { ollamaProvider } from "./ollama"; +import { ollamaCloudProvider } from "./ollama-cloud"; +import { openaiProvider } from "./openai"; +import { openaiCodexProvider } from "./openai-codex"; +import { openaiCodexDeviceProvider } from "./openai-codex-device"; +import { opencodeGoProvider } from "./opencode-go"; +import { opencodeZenProvider } from "./opencode-zen"; +import { openrouterProvider } from "./openrouter"; +import { parallelProvider } from "./parallel"; +import { perplexityProvider } from "./perplexity"; +import { qianfanProvider } from "./qianfan"; +import { qwenPortalProvider } from "./qwen-portal"; +import { syntheticProvider } from "./synthetic"; +import { tavilyProvider } from "./tavily"; +import { togetherProvider } from "./together"; +import type { ProviderDefinition } from "./types"; +import { veniceProvider } from "./venice"; +import { vercelAiGatewayProvider } from "./vercel-ai-gateway"; +import { vllmProvider } from "./vllm"; +import { waferPassProvider } from "./wafer-pass"; +import { waferServerlessProvider } from "./wafer-serverless"; +import { xaiProvider } from "./xai"; +import { xaiOauthProvider } from "./xai-oauth"; +import { xiaomiProvider } from "./xiaomi"; +import { xiaomiTokenPlanAmsProvider } from "./xiaomi-token-plan-ams"; +import { xiaomiTokenPlanCnProvider } from "./xiaomi-token-plan-cn"; +import { xiaomiTokenPlanSgpProvider } from "./xiaomi-token-plan-sgp"; +import { zaiProvider } from "./zai"; +import { zenmuxProvider } from "./zenmux"; +import { zhipuCodingPlanProvider } from "./zhipu-coding-plan"; + +/** + * The single per-provider list. Adding a provider = create `./providers/.ts` + * and add its export here. Every legacy structure (`KnownProvider`/`OAuthProvider` + * unions, descriptors, env map, login list, refresh/login dispatch, CLI callback + * maps) is derived from this registry. Order matches the interactive `/login` + * list for the loginable providers; non-login model providers are appended. + */ +const ALL = [ + openaiCodexProvider, + anthropicProvider, + zaiProvider, + kimiCodeProvider, + openrouterProvider, + githubCopilotProvider, + cursorProvider, + googleAntigravityProvider, + googleGeminiCliProvider, + openaiCodexDeviceProvider, + xaiOauthProvider, + gitlabDuoProvider, + alibabaCodingPlanProvider, + zhipuCodingPlanProvider, + qwenPortalProvider, + minimaxCodeProvider, + minimaxCodeCnProvider, + xiaomiProvider, + xiaomiTokenPlanSgpProvider, + xiaomiTokenPlanAmsProvider, + xiaomiTokenPlanCnProvider, + firepassProvider, + waferPassProvider, + deepseekProvider, + moonshotProvider, + cerebrasProvider, + fireworksProvider, + togetherProvider, + nvidiaProvider, + huggingfaceProvider, + perplexityProvider, + qianfanProvider, + veniceProvider, + syntheticProvider, + nanogptProvider, + waferServerlessProvider, + vercelAiGatewayProvider, + cloudflareAiGatewayProvider, + litellmProvider, + kiloProvider, + zenmuxProvider, + opencodeZenProvider, + opencodeGoProvider, + tavilyProvider, + kagiProvider, + parallelProvider, + ollamaProvider, + ollamaCloudProvider, + lmStudioProvider, + vllmProvider, + openaiProvider, + googleProvider, + googleVertexProvider, + xaiProvider, + groqProvider, + mistralProvider, + minimaxProvider, + amazonBedrockProvider, +]; + +export type RegistryDef = (typeof ALL)[number]; +export const PROVIDER_REGISTRY: readonly ProviderDefinition[] = ALL; + +const BY_ID = new Map(ALL.map(p => [p.id, p] as [string, ProviderDefinition])); + +export function getProviderDefinition(id: string): ProviderDefinition | undefined { + return BY_ID.get(id); +} + +/** Chat-model providers (those carrying a `defaultModel`). */ +export type KnownProviderId = Extract["id"]; +/** Loginable providers (those carrying a `login` flow). */ +export type OAuthProviderUnion = Extract["id"]; diff --git a/packages/ai/src/registry/synthetic.ts b/packages/ai/src/registry/synthetic.ts new file mode 100644 index 000000000..8e859911e --- /dev/null +++ b/packages/ai/src/registry/synthetic.ts @@ -0,0 +1,27 @@ +import { syntheticModelManagerOptions } from "../provider-models/openai-compat"; +import { createApiKeyLogin } from "./api-key-login"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const loginSynthetic = createApiKeyLogin({ + providerLabel: "Synthetic", + authUrl: "https://dev.synthetic.new/docs/api/overview", + instructions: "Copy your API key from the Synthetic dashboard", + promptMessage: "Paste your Synthetic API key", + placeholder: "sk-...", + validation: { + kind: "models-endpoint", + provider: "Synthetic", + modelsUrl: "https://api.synthetic.new/openai/v1/models", + }, +}); + +export const syntheticProvider = { + id: "synthetic", + name: "Synthetic", + defaultModel: "hf:zai-org/GLM-5.1", + createModelManagerOptions: (config: ModelManagerConfig) => syntheticModelManagerOptions(config), + dynamicModelsAuthoritative: true, + catalogDiscovery: { label: "Synthetic", envVars: ["SYNTHETIC_API_KEY"] }, + envKeys: "SYNTHETIC_API_KEY", + login: loginSynthetic, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/tavily.ts b/packages/ai/src/registry/tavily.ts similarity index 66% rename from packages/ai/src/utils/oauth/tavily.ts rename to packages/ai/src/registry/tavily.ts index cc319e641..bc07ab743 100644 --- a/packages/ai/src/utils/oauth/tavily.ts +++ b/packages/ai/src/registry/tavily.ts @@ -1,14 +1,5 @@ -/** - * Tavily login flow. - * - * Tavily web search uses an API key from the account settings page. - * This is an API key flow: - * 1. Open browser to Tavily settings - * 2. User copies API key - * 3. User pastes key into CLI - */ - -import type { OAuthController } from "./types"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ProviderDefinition } from "./types"; const AUTH_URL = "https://app.tavily.com/home"; @@ -18,7 +9,7 @@ const AUTH_URL = "https://app.tavily.com/home"; * Opens browser to API keys page and prompts user to paste their API key. * Returns the API key directly (not OAuthCredentials - this isn't OAuth). */ -export async function loginTavily(options: OAuthController): Promise { +export async function loginTavily(options: OAuthLoginCallbacks): Promise { if (!options.onPrompt) { throw new Error("Tavily login requires onPrompt callback"); } @@ -44,3 +35,10 @@ export async function loginTavily(options: OAuthController): Promise { return trimmed; } + +export const tavilyProvider = { + id: "tavily", + name: "Tavily", + envKeys: "TAVILY_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginTavily(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/together.ts b/packages/ai/src/registry/together.ts new file mode 100644 index 000000000..1b2c9ff6f --- /dev/null +++ b/packages/ai/src/registry/together.ts @@ -0,0 +1,27 @@ +import { togetherModelManagerOptions } from "../provider-models/openai-compat"; +import { createApiKeyLogin } from "./api-key-login"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const loginTogether = createApiKeyLogin({ + providerLabel: "Together", + authUrl: "https://api.together.xyz/settings/api-keys", + instructions: "Copy your API key from the Together dashboard", + promptMessage: "Paste your Together API key", + placeholder: "sk-...", + validation: { + kind: "chat-completions", + provider: "together", + baseUrl: "https://api.together.xyz/v1", + model: "moonshotai/Kimi-K2.5", + }, +}); + +export const togetherProvider = { + id: "together", + name: "Together", + defaultModel: "moonshotai/Kimi-K2.5", + createModelManagerOptions: (config: ModelManagerConfig) => togetherModelManagerOptions(config), + catalogDiscovery: { label: "Together", envVars: ["TOGETHER_API_KEY"] }, + envKeys: "TOGETHER_API_KEY", + login: (cb: Parameters[0]) => loginTogether(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/types.ts b/packages/ai/src/registry/types.ts new file mode 100644 index 000000000..1a65244fa --- /dev/null +++ b/packages/ai/src/registry/types.ts @@ -0,0 +1,118 @@ +/** + * Single-source provider model. Every provider — model providers, gateways, + * search/tool credentials, and login-only flows — is described by one + * {@link ProviderDefinition}. The legacy scattered structures (the + * `KnownProvider`/`OAuthProvider` unions, `PROVIDER_DESCRIPTORS`, + * `serviceProviderMap`, `builtInOAuthProviders`, the refresh/login switches, + * and the CLI callback maps) are all *derived* from the registry of these + * definitions. Adding a provider is one new file in `./providers/` plus one + * line in `./registry.ts`. + */ +import type { ModelManagerOptions } from "../model-manager"; +import type { Api } from "../types"; +import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; + +/** Config passed to a provider's runtime model-manager factory. */ +export type ModelManagerConfig = { apiKey?: string; baseUrl?: string }; + +/** + * API-key environment fallback: either a single env var name (e.g. + * `"OPENAI_API_KEY"`) or a resolver that inspects several env vars / probes + * the host (Vertex ADC, Bedrock credential chains, …). + */ +export type KeyResolver = string | (() => string | undefined); + +/** Catalog discovery configuration for providers that support endpoint-based model listing. */ +export interface CatalogDiscoveryConfig { + /** Human-readable name for log messages. */ + label: string; + /** Environment variables to check for API keys during catalog generation. */ + envVars: readonly string[]; + /** OAuth provider for credential refresh during catalog generation. */ + oauthProvider?: string; + /** When true, catalog discovery proceeds even without credentials. */ + allowUnauthenticated?: boolean; +} + +/** Unified provider descriptor used by both runtime discovery and catalog generation. */ +export interface ProviderDescriptor { + providerId: string; + createModelManagerOptions(config: ModelManagerConfig): ModelManagerOptions; + /** Preferred model ID when no explicit selection is made. */ + defaultModel: string; + /** When true, the runtime creates a model manager even without a valid API key (e.g. ollama). */ + allowUnauthenticated?: boolean; + /** When true, successful runtime discovery replaces bundled provider models instead of merging fallback-only IDs. */ + dynamicModelsAuthoritative?: boolean; + /** Catalog discovery configuration. Only providers with this field participate in generate-models.ts. */ + catalogDiscovery?: CatalogDiscoveryConfig; +} + +/** A provider descriptor that has catalog discovery configured. */ +export type CatalogProviderDescriptor = ProviderDescriptor & { catalogDiscovery: CatalogDiscoveryConfig }; + +/** Type guard for descriptors with catalog discovery. */ +export function isCatalogDescriptor(d: ProviderDescriptor): d is CatalogProviderDescriptor { + return d.catalogDiscovery != null; +} + +/** Whether catalog discovery may run without provider credentials. */ +export function allowsUnauthenticatedCatalogDiscovery(descriptor: CatalogProviderDescriptor): boolean { + return descriptor.catalogDiscovery.allowUnauthenticated ?? descriptor.allowUnauthenticated ?? false; +} + +/** + * Declarative description of a single provider. All fields are optional except + * `id`/`name`; presence of a field opts the provider into a derived structure: + * + * - `defaultModel` present ⇒ member of `KnownProvider` (a chat-model provider). + * - `createModelManagerOptions` present (and not `specialModelManager`) ⇒ + * appears in `PROVIDER_DESCRIPTORS` for runtime model discovery. + * - `envKeys` present ⇒ env-var fallback in `getEnvApiKey`. + * - `login` present ⇒ member of `OAuthProvider`, shown in the `/login` list + * (unless `showInLoginList === false`) and dispatchable via `AuthStorage.login`. + * - `callbackPort` present ⇒ entry in the auth-broker `CALLBACK_PORTS` map. + * - `pasteCodeFlow` ⇒ member of `PASTE_CODE_LOGIN_PROVIDERS`. + * + * Heavy OAuth flow modules MUST be reached through dynamic-import thunks in + * `login`/`refreshToken` so they stay out of the eager startup graph. + */ +export interface ProviderDefinition { + readonly id: string; + readonly name: string; + /** Login-list availability flag. Defaults to true when shown. */ + readonly available?: boolean; + /** Whether to surface in the interactive login list. Defaults to true when `login` is present. */ + readonly showInLoginList?: boolean; + // --- model discovery --- + /** Preferred model ID when no explicit selection is made. Presence ⇒ `KnownProvider` member. */ + readonly defaultModel?: string; + /** Runtime model-manager factory. Omitted for login-only tools and catalog-only providers. */ + readonly createModelManagerOptions?: (config: ModelManagerConfig) => ModelManagerOptions; + /** When true, the runtime creates a model manager even without a valid API key. */ + readonly allowUnauthenticated?: boolean; + /** When true, successful runtime discovery replaces bundled provider models. */ + readonly dynamicModelsAuthoritative?: boolean; + /** Catalog discovery configuration for generate-models.ts. */ + readonly catalogDiscovery?: CatalogDiscoveryConfig; + /** + * Providers whose model manager is constructed bespoke in the coding-agent + * runtime (`google-antigravity`/`google-gemini-cli`/`openai-codex`). Excluded + * from the derived `PROVIDER_DESCRIPTORS`; the registry supplies only their + * identity/login/refresh/default-model metadata. + */ + readonly specialModelManager?: boolean; + // --- env-var fallback --- + readonly envKeys?: KeyResolver; + // --- interactive login (OAuthProviderInterface-compatible) --- + readonly login?: (callbacks: OAuthLoginCallbacks) => Promise; + readonly refreshToken?: (credentials: OAuthCredentials) => Promise; + readonly getApiKey?: (credentials: OAuthCredentials) => string; + /** Store OAuth credentials under a different provider id (e.g. `openai-codex-device` ⇒ `openai-codex`). */ + readonly storeCredentialsAs?: string; + // --- coding-agent login UX --- + /** Auth-broker local callback-server port. Presence ⇒ entry in `CALLBACK_PORTS`. */ + readonly callbackPort?: number; + /** OAuth flow needs a pasted code/redirect URL rather than a callback server. */ + readonly pasteCodeFlow?: boolean; +} diff --git a/packages/ai/src/utils/oauth/venice.ts b/packages/ai/src/registry/venice.ts similarity index 65% rename from packages/ai/src/utils/oauth/venice.ts rename to packages/ai/src/registry/venice.ts index fd378d800..f9fb3dee6 100644 --- a/packages/ai/src/utils/oauth/venice.ts +++ b/packages/ai/src/registry/venice.ts @@ -1,16 +1,7 @@ -/** - * Venice login flow. - * - * Venice provides OpenAI-compatible models via https://api.venice.ai/api/v1. - * - * This is not OAuth - it's a simple API key flow: - * 1. Open browser to Venice API key settings - * 2. User copies their API key - * 3. User pastes the API key into the CLI - */ - +import { veniceModelManagerOptions } from "../provider-models/openai-compat"; import { validateOpenAICompatibleApiKey } from "./api-key-validation"; -import type { OAuthController } from "./types"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const AUTH_URL = "https://venice.ai/settings/api"; const API_BASE_URL = "https://api.venice.ai/api/v1"; @@ -57,3 +48,13 @@ export async function loginVenice(options: OAuthController): Promise { return trimmed; } + +export const veniceProvider = { + id: "venice", + name: "Venice", + defaultModel: "llama-3.3-70b", + createModelManagerOptions: (config: ModelManagerConfig) => veniceModelManagerOptions(config), + catalogDiscovery: { label: "Venice", envVars: ["VENICE_API_KEY"], allowUnauthenticated: true }, + envKeys: "VENICE_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginVenice(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/vercel-ai-gateway.ts b/packages/ai/src/registry/vercel-ai-gateway.ts similarity index 50% rename from packages/ai/src/utils/oauth/vercel-ai-gateway.ts rename to packages/ai/src/registry/vercel-ai-gateway.ts index 3163c4869..5c3e266b2 100644 --- a/packages/ai/src/utils/oauth/vercel-ai-gateway.ts +++ b/packages/ai/src/registry/vercel-ai-gateway.ts @@ -1,24 +1,9 @@ -/** - * Vercel AI Gateway login flow. - * - * Vercel AI Gateway proxies upstream model providers through a unified endpoint. - * - * This is not OAuth - it's a simple API key flow: - * 1. Open Vercel AI Gateway docs - * 2. User copies their API key - * 3. User pastes the API key into the CLI - */ - -import type { OAuthController } from "./types"; +import { vercelAiGatewayModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const AUTH_URL = "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway%2Fapi-keys&title=AI+Gateway+API+Keys"; -/** - * Login to Vercel AI Gateway. - * - * Opens browser to Vercel AI Gateway docs and prompts for an API key. - * Returns the API key directly (not OAuthCredentials - this isn't OAuth). - */ export async function loginVercelAiGateway(options: OAuthController): Promise { if (!options.onPrompt) { throw new Error("Vercel AI Gateway login requires onPrompt callback"); @@ -45,3 +30,13 @@ export async function loginVercelAiGateway(options: OAuthController): Promise vercelAiGatewayModelManagerOptions(config), + catalogDiscovery: { label: "Vercel AI Gateway", envVars: ["VERCEL_AI_GATEWAY_API_KEY"], allowUnauthenticated: true }, + envKeys: "AI_GATEWAY_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginVercelAiGateway(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/vllm.ts b/packages/ai/src/registry/vllm.ts similarity index 58% rename from packages/ai/src/utils/oauth/vllm.ts rename to packages/ai/src/registry/vllm.ts index d2e10db1d..3e175be77 100644 --- a/packages/ai/src/utils/oauth/vllm.ts +++ b/packages/ai/src/registry/vllm.ts @@ -1,24 +1,12 @@ -/** - * vLLM login flow. - * - * vLLM is commonly self-hosted with an OpenAI-compatible API at a local base URL. - * Some deployments require a bearer token, others allow unauthenticated access. - * - * This flow stores an API-key-style credential used by `/login` and auth storage. - */ - -import type { OAuthController, OAuthProvider } from "./types"; +import { vllmModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthController, OAuthLoginCallbacks, OAuthProvider } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const PROVIDER_ID: OAuthProvider = "vllm"; const AUTH_URL = "https://docs.vllm.ai/en/latest/serving/openai_compatible_server.html"; const DEFAULT_LOCAL_BASE_URL = "http://127.0.0.1:8000/v1"; const DEFAULT_LOCAL_TOKEN = "vllm-local"; -/** - * Login to vLLM. - * - * Opens vLLM OpenAI-compatible auth docs, prompts for an optional token, - * and returns a stored key value. - */ + export async function loginVllm(options: OAuthController): Promise { if (!options.onPrompt) { throw new Error(`${PROVIDER_ID} login requires onPrompt callback`); @@ -38,3 +26,13 @@ export async function loginVllm(options: OAuthController): Promise { const trimmed = apiKey.trim(); return trimmed || DEFAULT_LOCAL_TOKEN; } + +export const vllmProvider = { + id: "vllm", + name: "vLLM (Local OpenAI-compatible)", + defaultModel: "gpt-oss-20b", + createModelManagerOptions: (config: ModelManagerConfig) => vllmModelManagerOptions(config), + catalogDiscovery: { label: "vLLM", envVars: ["VLLM_API_KEY"], allowUnauthenticated: true }, + envKeys: "VLLM_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginVllm(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/wafer-pass.ts b/packages/ai/src/registry/wafer-pass.ts new file mode 100644 index 000000000..357b05f06 --- /dev/null +++ b/packages/ai/src/registry/wafer-pass.ts @@ -0,0 +1,17 @@ +import { waferPassModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const waferPassProvider = { + id: "wafer-pass", + name: "Wafer Pass (flat-rate subscription)", + defaultModel: "GLM-5.1", + createModelManagerOptions: (config: ModelManagerConfig) => waferPassModelManagerOptions(config), + catalogDiscovery: { label: "Wafer Pass", envVars: ["WAFER_PASS_API_KEY"], oauthProvider: "wafer-pass" }, + envKeys: "WAFER_PASS_API_KEY", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginWaferPass } = await import("./oauth/wafer"); + return loginWaferPass(cb); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/wafer-serverless.ts b/packages/ai/src/registry/wafer-serverless.ts new file mode 100644 index 000000000..627c34f96 --- /dev/null +++ b/packages/ai/src/registry/wafer-serverless.ts @@ -0,0 +1,21 @@ +import { waferServerlessModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const waferServerlessProvider = { + id: "wafer-serverless", + name: "Wafer Serverless (pay-as-you-go)", + defaultModel: "GLM-5.1", + createModelManagerOptions: (config: ModelManagerConfig) => waferServerlessModelManagerOptions(config), + catalogDiscovery: { + label: "Wafer Serverless", + envVars: ["WAFER_SERVERLESS_API_KEY"], + oauthProvider: "wafer-serverless", + }, + envKeys: "WAFER_SERVERLESS_API_KEY", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginWaferServerless } = await import("./oauth/wafer"); + return loginWaferServerless(cb); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/xai-oauth.ts b/packages/ai/src/registry/xai-oauth.ts new file mode 100644 index 000000000..fe020b24e --- /dev/null +++ b/packages/ai/src/registry/xai-oauth.ts @@ -0,0 +1,27 @@ +import { $pickenv } from "@oh-my-pi/pi-utils"; +import { xaiOAuthModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const xaiOauthProvider = { + id: "xai-oauth", + name: "xAI Grok OAuth (SuperGrok Subscription)", + defaultModel: "grok-4.3", + createModelManagerOptions: (config: ModelManagerConfig) => xaiOAuthModelManagerOptions(config), + catalogDiscovery: { + label: "xAI Grok OAuth (SuperGrok)", + envVars: ["XAI_OAUTH_TOKEN", "XAI_API_KEY"], + oauthProvider: "xai-oauth", + }, + envKeys: () => $pickenv("XAI_OAUTH_TOKEN", "XAI_API_KEY"), + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginXAIOAuth } = await import("./oauth/xai-oauth"); + return loginXAIOAuth(cb); + }, + refreshToken: async (credentials: OAuthCredentials) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { refreshXAIOAuthToken } = await import("./oauth/xai-oauth"); + return refreshXAIOAuthToken(credentials.refresh); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/xai.ts b/packages/ai/src/registry/xai.ts new file mode 100644 index 000000000..1afc01545 --- /dev/null +++ b/packages/ai/src/registry/xai.ts @@ -0,0 +1,10 @@ +import { xaiModelManagerOptions } from "../provider-models/openai-compat"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const xaiProvider = { + id: "xai", + name: "xAI", + defaultModel: "grok-4-fast-non-reasoning", + createModelManagerOptions: (config: ModelManagerConfig) => xaiModelManagerOptions(config), + envKeys: "XAI_API_KEY", +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/xiaomi-token-plan-ams.ts b/packages/ai/src/registry/xiaomi-token-plan-ams.ts new file mode 100644 index 000000000..bd1e13ad8 --- /dev/null +++ b/packages/ai/src/registry/xiaomi-token-plan-ams.ts @@ -0,0 +1,17 @@ +import { xiaomiModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const xiaomiTokenPlanAmsProvider = { + id: "xiaomi-token-plan-ams", + name: "Xiaomi Token Plan (Europe)", + defaultModel: "mimo-v2.5", + createModelManagerOptions: (config: ModelManagerConfig) => + xiaomiModelManagerOptions({ ...config, providerId: "xiaomi-token-plan-ams", tokenPlanRegion: "ams" }), + envKeys: "XIAOMI_TOKEN_PLAN_AMS_API_KEY", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginXiaomiTokenPlan } = await import("./oauth/xiaomi"); + return loginXiaomiTokenPlan(cb, "ams"); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/xiaomi-token-plan-cn.ts b/packages/ai/src/registry/xiaomi-token-plan-cn.ts new file mode 100644 index 000000000..c0d4fcdf8 --- /dev/null +++ b/packages/ai/src/registry/xiaomi-token-plan-cn.ts @@ -0,0 +1,17 @@ +import { xiaomiModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const xiaomiTokenPlanCnProvider = { + id: "xiaomi-token-plan-cn", + name: "Xiaomi Token Plan (China)", + defaultModel: "mimo-v2.5", + createModelManagerOptions: (config: ModelManagerConfig) => + xiaomiModelManagerOptions({ ...config, providerId: "xiaomi-token-plan-cn", tokenPlanRegion: "cn" }), + envKeys: "XIAOMI_TOKEN_PLAN_CN_API_KEY", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginXiaomiTokenPlan } = await import("./oauth/xiaomi"); + return loginXiaomiTokenPlan(cb, "cn"); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/xiaomi-token-plan-sgp.ts b/packages/ai/src/registry/xiaomi-token-plan-sgp.ts new file mode 100644 index 000000000..63a9de7b6 --- /dev/null +++ b/packages/ai/src/registry/xiaomi-token-plan-sgp.ts @@ -0,0 +1,17 @@ +import { xiaomiModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const xiaomiTokenPlanSgpProvider = { + id: "xiaomi-token-plan-sgp", + name: "Xiaomi Token Plan (Singapore)", + defaultModel: "mimo-v2.5", + createModelManagerOptions: (config: ModelManagerConfig) => + xiaomiModelManagerOptions({ ...config, providerId: "xiaomi-token-plan-sgp", tokenPlanRegion: "sgp" }), + envKeys: "XIAOMI_TOKEN_PLAN_SGP_API_KEY", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginXiaomiTokenPlan } = await import("./oauth/xiaomi"); + return loginXiaomiTokenPlan(cb, "sgp"); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/xiaomi.ts b/packages/ai/src/registry/xiaomi.ts new file mode 100644 index 000000000..ea56a313e --- /dev/null +++ b/packages/ai/src/registry/xiaomi.ts @@ -0,0 +1,17 @@ +import { xiaomiModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const xiaomiProvider = { + id: "xiaomi", + name: "Xiaomi MiMo", + defaultModel: "mimo-v2-flash", + createModelManagerOptions: (config: ModelManagerConfig) => xiaomiModelManagerOptions(config), + catalogDiscovery: { label: "Xiaomi", envVars: ["XIAOMI_API_KEY"] }, + envKeys: "XIAOMI_API_KEY", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginXiaomi } = await import("./oauth/xiaomi"); + return loginXiaomi(cb); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/zai.ts b/packages/ai/src/registry/zai.ts similarity index 60% rename from packages/ai/src/utils/oauth/zai.ts rename to packages/ai/src/registry/zai.ts index 90b84ba44..641e0147b 100644 --- a/packages/ai/src/utils/oauth/zai.ts +++ b/packages/ai/src/registry/zai.ts @@ -1,39 +1,22 @@ -/** - * Z.AI login flow. - * - * Z.AI is a platform that provides access to GLM models through an OpenAI-compatible API. - * API docs: https://docs.z.ai/guides/overview/quick-start - * - * This is not OAuth - it's a simple API key flow: - * 1. User gets their API key from https://z.ai/settings/api-keys - * 2. User pastes the API key into the CLI - */ - +import { zaiModelManagerOptions } from "../provider-models/special"; import { validateOpenAICompatibleApiKey } from "./api-key-validation"; -import type { OAuthController } from "./types"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const AUTH_URL = "https://z.ai/manage-apikey/apikey-list"; const API_BASE_URL = "https://api.z.ai/api/coding/paas/v4"; const VALIDATION_MODEL = "glm-4.7"; -/** - * Login to Z.AI. - * - * Opens browser to API keys page, prompts user to paste their API key. - * Returns the API key directly (not OAuthCredentials - this isn't OAuth). - */ export async function loginZai(options: OAuthController): Promise { if (!options.onPrompt) { throw new Error("Z.AI login requires onPrompt callback"); } - // Open browser to API keys page options.onAuth?.({ url: AUTH_URL, instructions: "Copy your API key from the dashboard", }); - // Prompt user to paste their API key const apiKey = await options.onPrompt({ message: "Paste your Z.AI API key", placeholder: "sk-...", @@ -58,3 +41,13 @@ export async function loginZai(options: OAuthController): Promise { }); return trimmed; } + +export const zaiProvider = { + id: "zai", + name: "Z.AI (GLM Coding Plan)", + defaultModel: "glm-5.1", + createModelManagerOptions: (config: ModelManagerConfig) => zaiModelManagerOptions(config), + catalogDiscovery: { label: "zAI", envVars: ["ZAI_API_KEY"] }, + envKeys: "ZAI_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginZai(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/zenmux.ts b/packages/ai/src/registry/zenmux.ts new file mode 100644 index 000000000..233759b98 --- /dev/null +++ b/packages/ai/src/registry/zenmux.ts @@ -0,0 +1,27 @@ +import { zenmuxModelManagerOptions } from "../provider-models/openai-compat"; +import { createApiKeyLogin } from "./api-key-login"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const loginZenMux = createApiKeyLogin({ + providerLabel: "ZenMux", + authUrl: "https://zenmux.ai/settings/keys", + instructions: "Create or copy your ZenMux API key", + promptMessage: "Paste your ZenMux API key", + placeholder: "sk-...", + validation: { + kind: "models-endpoint", + provider: "ZenMux", + modelsUrl: "https://zenmux.ai/api/v1/models", + }, +}); + +export const zenmuxProvider = { + id: "zenmux", + name: "ZenMux", + defaultModel: "anthropic/claude-opus-4.6", + createModelManagerOptions: (config: ModelManagerConfig) => zenmuxModelManagerOptions(config), + catalogDiscovery: { label: "ZenMux", envVars: ["ZENMUX_API_KEY"] }, + envKeys: "ZENMUX_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginZenMux(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/zhipu.ts b/packages/ai/src/registry/zhipu-coding-plan.ts similarity index 59% rename from packages/ai/src/utils/oauth/zhipu.ts rename to packages/ai/src/registry/zhipu-coding-plan.ts index 14c766ed3..566403888 100644 --- a/packages/ai/src/utils/oauth/zhipu.ts +++ b/packages/ai/src/registry/zhipu-coding-plan.ts @@ -1,42 +1,25 @@ -/** - * Zhipu Coding Plan login flow. - * - * GLM Coding Plan provides an OpenAI-compatible API on the dedicated coding - * endpoint. API docs: https://docs.bigmodel.cn/cn/coding-plan/quick-start - * - * Simple API key flow: - * 1. User gets a Coding Plan API key from https://bigmodel.cn/coding-plan/personal/overview - * 2. User pastes the API key into the CLI - */ - +import { zhipuCodingPlanModelManagerOptions } from "../provider-models/openai-compat"; import { validateOpenAICompatibleApiKey } from "./api-key-validation"; -import type { OAuthController } from "./types"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const AUTH_URL = "https://bigmodel.cn/coding-plan/personal/overview"; const API_BASE_URL = "https://open.bigmodel.cn/api/coding/paas/v4"; const VALIDATION_MODEL = "glm-5.1"; -/** - * Login to Zhipu Coding Plan. - * - * Opens browser to API keys page, prompts user to paste their API key. - * Returns the API key directly (not OAuthCredentials - this isn't OAuth). - */ export async function loginZhipuCodingPlan(options: OAuthController): Promise { if (!options.onPrompt) { throw new Error("Zhipu Coding Plan login requires onPrompt callback"); } - // Open browser to API keys page options.onAuth?.({ url: AUTH_URL, instructions: "Copy your API key from the Coding Plan dashboard", }); - // Prompt user to paste their API key const apiKey = await options.onPrompt({ message: "Paste your Zhipu API key", - placeholder: "sk-...", + placeholder: ".", }); if (options.signal?.aborted) { @@ -58,3 +41,13 @@ export async function loginZhipuCodingPlan(options: OAuthController): Promise zhipuCodingPlanModelManagerOptions(config), + catalogDiscovery: { label: "Zhipu Coding Plan", envVars: ["ZHIPU_API_KEY"] }, + envKeys: "ZHIPU_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginZhipuCodingPlan(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index c081a80c8..03d273a51 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -1,7 +1,4 @@ -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { $env, $pickenv, extractHttpStatusFromError } from "@oh-my-pi/pi-utils"; +import { $env, extractHttpStatusFromError } from "@oh-my-pi/pi-utils"; import { getCustomApi } from "./api-registry"; import { AUTH_RETRY_STEPS, isApiKeyResolver, resolveRetryKey } from "./auth-retry"; import type { Effort } from "./effort"; @@ -47,6 +44,7 @@ import { import { isSyntheticModel, streamSynthetic } from "./providers/synthetic"; import { streamXAIResponses } from "./providers/xai-responses"; import { isUsageLimitError } from "./rate-limit-utils"; +import { PROVIDER_REGISTRY } from "./registry"; import type { Api, AssistantMessage, @@ -61,24 +59,8 @@ import type { ToolChoice, } from "./types"; import { AssistantMessageEventStream } from "./utils/event-stream"; -import { isFoundryEnabled } from "./utils/foundry"; import { withRequestDebugFetch } from "./utils/request-debug"; -let cachedVertexAdcCredentialsExists: boolean | null = null; - -function hasVertexAdcCredentials(): boolean { - if (cachedVertexAdcCredentialsExists === null) { - const gacPath = $env.GOOGLE_APPLICATION_CREDENTIALS; - if (gacPath) { - cachedVertexAdcCredentialsExists = fs.existsSync(gacPath); - } else { - cachedVertexAdcCredentialsExists = fs.existsSync( - path.join(os.homedir(), ".config", "gcloud", "application_default_credentials.json"), - ); - } - } - return cachedVertexAdcCredentialsExists; -} function isGoogleVertexAuthenticatedModel(model: Model): boolean { return ( model.provider === "google-vertex" && @@ -175,100 +157,22 @@ function resolveVertexRequest(input: string | URL | Request): string | URL | Req type KeyResolver = string | (() => string | undefined); -const serviceProviderMap: Record = { - "alibaba-coding-plan": "ALIBABA_CODING_PLAN_API_KEY", - openai: "OPENAI_API_KEY", - google: "GEMINI_API_KEY", - groq: "GROQ_API_KEY", - cerebras: "CEREBRAS_API_KEY", - xai: "XAI_API_KEY", - "xai-oauth": () => $pickenv("XAI_OAUTH_TOKEN", "XAI_API_KEY"), - fireworks: "FIREWORKS_API_KEY", - firepass: "FIREPASS_API_KEY", - "wafer-pass": "WAFER_PASS_API_KEY", - "wafer-serverless": "WAFER_SERVERLESS_API_KEY", - openrouter: "OPENROUTER_API_KEY", - kilo: "KILO_API_KEY", - "vercel-ai-gateway": "AI_GATEWAY_API_KEY", - zai: "ZAI_API_KEY", - "zhipu-coding-plan": "ZHIPU_API_KEY", - mistral: "MISTRAL_API_KEY", - minimax: "MINIMAX_API_KEY", - "minimax-code": "MINIMAX_CODE_API_KEY", - "minimax-code-cn": "MINIMAX_CODE_CN_API_KEY", - "opencode-go": "OPENCODE_API_KEY", - "opencode-zen": "OPENCODE_API_KEY", - cursor: "CURSOR_ACCESS_TOKEN", - deepseek: "DEEPSEEK_API_KEY", - "openai-codex": "OPENAI_CODEX_OAUTH_TOKEN", +const LEGACY_ENV_KEYS: Record = { + // Non-provider / search-tool keys and API-name keys not modeled as registry provider defs. "azure-openai-responses": "AZURE_OPENAI_API_KEY", + "llama.cpp": "LLAMA_CPP_API_KEY", exa: "EXA_API_KEY", jina: "JINA_API_KEY", brave: "BRAVE_API_KEY", - perplexity: "PERPLEXITY_API_KEY", - tavily: "TAVILY_API_KEY", - parallel: "PARALLEL_API_KEY", - kagi: "KAGI_API_KEY", - "github-copilot": "COPILOT_GITHUB_TOKEN", - // Foundry mode optionally switches Anthropic auth to enterprise gateway credentials. - anthropic: () => - isFoundryEnabled() - ? $pickenv("ANTHROPIC_FOUNDRY_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY") - : $pickenv("ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"), - "gitlab-duo": "GITLAB_TOKEN", - // Vertex AI supports either GOOGLE_CLOUD_API_KEY or Application Default Credentials. - "google-vertex": () => { - if ($env.GOOGLE_CLOUD_API_KEY) { - return $env.GOOGLE_CLOUD_API_KEY; - } - const hasCredentials = hasVertexAdcCredentials(); - const hasProject = !!($env.GOOGLE_CLOUD_PROJECT || $env.GCP_PROJECT || $env.GCLOUD_PROJECT); - const hasLocation = !!($env.GOOGLE_VERTEX_LOCATION || $env.GOOGLE_CLOUD_LOCATION || $env.VERTEX_LOCATION); - if (hasCredentials && hasProject && hasLocation) { - return ""; - } - }, - // Amazon Bedrock supports multiple credential sources: - // 1. AWS_BEARER_TOKEN_BEDROCK - Bedrock API keys (bearer token) - // 2. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY - standard IAM keys - // 3. AWS_PROFILE - named profile from ~/.aws/credentials - // 4. AWS_CONTAINER_CREDENTIALS_* - ECS/Task IAM role credentials - // 5. AWS_WEB_IDENTITY_TOKEN_FILE + AWS_ROLE_ARN - IRSA (EKS) web identity - "amazon-bedrock": () => { - const hasEcsCredentials = - !!$env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || !!$env.AWS_CONTAINER_CREDENTIALS_FULL_URI; - const hasWebIdentity = !!$env.AWS_WEB_IDENTITY_TOKEN_FILE && !!$env.AWS_ROLE_ARN; - if ( - $env.AWS_PROFILE || - ($env.AWS_ACCESS_KEY_ID && $env.AWS_SECRET_ACCESS_KEY) || - $env.AWS_BEARER_TOKEN_BEDROCK || - hasEcsCredentials || - hasWebIdentity - ) { - return ""; - } - }, - synthetic: "SYNTHETIC_API_KEY", - "cloudflare-ai-gateway": "CLOUDFLARE_AI_GATEWAY_API_KEY", - huggingface: () => $pickenv("HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"), - litellm: "LITELLM_API_KEY", - moonshot: "MOONSHOT_API_KEY", - nvidia: "NVIDIA_API_KEY", - nanogpt: "NANO_GPT_API_KEY", - "lm-studio": "LM_STUDIO_API_KEY", - ollama: "OLLAMA_API_KEY", - "ollama-cloud": "OLLAMA_CLOUD_API_KEY", - "llama.cpp": "LLAMA_CPP_API_KEY", - qianfan: "QIANFAN_API_KEY", - "qwen-portal": () => $pickenv("QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"), - together: "TOGETHER_API_KEY", - zenmux: "ZENMUX_API_KEY", - venice: "VENICE_API_KEY", - vllm: "VLLM_API_KEY", - xiaomi: "XIAOMI_API_KEY", - "xiaomi-token-plan-sgp": "XIAOMI_TOKEN_PLAN_SGP_API_KEY", - "xiaomi-token-plan-ams": "XIAOMI_TOKEN_PLAN_AMS_API_KEY", - "xiaomi-token-plan-cn": "XIAOMI_TOKEN_PLAN_CN_API_KEY", +}; + +const serviceProviderMap: Record = { + ...Object.fromEntries( + PROVIDER_REGISTRY.flatMap(provider => + provider.envKeys != null ? [[provider.id, provider.envKeys] as [string, KeyResolver]] : [], + ), + ), + ...LEGACY_ENV_KEYS, }; /** diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 566574be4..00aebceba 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -28,6 +28,7 @@ import type { OllamaChatOptions } from "./providers/ollama"; import type { OpenAICodexResponsesOptions } from "./providers/openai-codex-responses"; import type { OpenAICompletionsOptions } from "./providers/openai-completions"; import type { OpenAIResponsesOptions } from "./providers/openai-responses"; +import type { KnownProviderId } from "./registry"; import type { AssistantMessageEventStream } from "./utils/event-stream"; export type { AssistantMessageEventStream } from "./utils/event-stream"; @@ -96,61 +97,12 @@ export interface ThinkingConfig { mode: ThinkingControlMode; } -export type KnownProvider = - | "alibaba-coding-plan" - | "amazon-bedrock" - | "anthropic" - | "google" - | "google-gemini-cli" - | "google-antigravity" - | "google-vertex" - | "openai" - | "openai-codex" - | "kimi-code" - | "minimax-code" - | "minimax-code-cn" - | "github-copilot" - | "fireworks" - | "firepass" - | "gitlab-duo" - | "cursor" - | "deepseek" - | "xai" - | "xai-oauth" - | "groq" - | "cerebras" - | "openrouter" - | "kilo" - | "vercel-ai-gateway" - | "zai" - | "zhipu-coding-plan" - | "mistral" - | "minimax" - | "opencode-go" - | "opencode-zen" - | "synthetic" - | "cloudflare-ai-gateway" - | "huggingface" - | "litellm" - | "moonshot" - | "nvidia" - | "nanogpt" - | "ollama" - | "ollama-cloud" - | "qianfan" - | "qwen-portal" - | "together" - | "venice" - | "vllm" - | "xiaomi" - | "xiaomi-token-plan-sgp" - | "xiaomi-token-plan-ams" - | "xiaomi-token-plan-cn" - | "wafer-pass" - | "wafer-serverless" - | "zenmux" - | "lm-studio"; -export type Provider = KnownProvider | string; +export type KnownProvider = KnownProviderId; +// `Provider` is any provider-id string; `KnownProvider` enumerates the built-in model +// providers. Kept structurally `string` (the prior `KnownProvider | string` already +// collapsed to `string`) so the registry-derived `KnownProvider` can reference the model +// types below without forming a circular type-alias reference. +export type Provider = string; import type { Effort } from "./effort"; diff --git a/packages/ai/src/usage/github-copilot.ts b/packages/ai/src/usage/github-copilot.ts index 82add1289..a810ebb06 100644 --- a/packages/ai/src/usage/github-copilot.ts +++ b/packages/ai/src/usage/github-copilot.ts @@ -3,6 +3,8 @@ * * Normalizes Copilot quota usage into the shared UsageReport schema. */ + +import { OPENCODE_HEADERS } from "../registry/oauth/github-copilot"; import type { UsageAmount, UsageFetchContext, @@ -14,7 +16,6 @@ import type { UsageWindow, } from "../usage"; import { isRecord, toBoolean, toNumber } from "../utils"; -import { OPENCODE_HEADERS } from "../utils/oauth/github-copilot"; type CopilotQuotaDetail = { entitlement: number; diff --git a/packages/ai/src/usage/kimi.ts b/packages/ai/src/usage/kimi.ts index edbfe4549..17c6a2e6f 100644 --- a/packages/ai/src/usage/kimi.ts +++ b/packages/ai/src/usage/kimi.ts @@ -1,4 +1,5 @@ import { $env } from "@oh-my-pi/pi-utils"; +import { getKimiCommonHeaders } from "../registry/oauth/kimi"; import type { UsageAmount, UsageFetchContext, @@ -10,7 +11,6 @@ import type { UsageWindow, } from "../usage"; import { isRecord } from "../utils"; -import { getKimiCommonHeaders } from "../utils/oauth/kimi"; // (Refresh is the sole responsibility of AuthStorage; no provider-direct refresh here.) import { toNumber } from "./shared"; diff --git a/packages/ai/src/utils/discovery/gemini.ts b/packages/ai/src/utils/discovery/gemini.ts index 7657d10ba..09e0163fc 100644 --- a/packages/ai/src/utils/discovery/gemini.ts +++ b/packages/ai/src/utils/discovery/gemini.ts @@ -1,6 +1,6 @@ -import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "@oh-my-pi/pi-ai"; import * as z from "zod/v4"; import { getBundledModels } from "../../models"; +import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "../../provider-models/discovery-constants"; import type { Model } from "../../types"; const GOOGLE_GENERATIVE_AI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; diff --git a/packages/ai/src/utils/discovery/openai-compatible.ts b/packages/ai/src/utils/discovery/openai-compatible.ts index 07ad8c38b..3dc654c75 100644 --- a/packages/ai/src/utils/discovery/openai-compatible.ts +++ b/packages/ai/src/utils/discovery/openai-compatible.ts @@ -1,5 +1,5 @@ -import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "@oh-my-pi/pi-ai"; import * as z from "zod/v4"; +import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "../../provider-models/discovery-constants"; import type { Api, Model, Provider } from "../../types"; const MODELS_PATH = "/models"; diff --git a/packages/ai/src/utils/idle-iterator.ts b/packages/ai/src/utils/idle-iterator.ts index d19b5cd57..f1c5345b0 100644 --- a/packages/ai/src/utils/idle-iterator.ts +++ b/packages/ai/src/utils/idle-iterator.ts @@ -124,8 +124,11 @@ export async function* iterateWithIdleTimeout( firstItemTimeoutMs !== undefined && firstItemTimeoutMs > 0 ? Date.now() + firstItemTimeoutMs : undefined; const abortSignal = options.abortSignal; const iterator = iterable[Symbol.asyncIterator](); + let iteratorClosed = false; const closeIterator = (): void => { + if (iteratorClosed) return; + iteratorClosed = true; const returnPromise = iterator.return?.(); if (returnPromise) { void returnPromise.catch(() => {}); @@ -212,6 +215,12 @@ export async function* iterateWithIdleTimeout( racers.push(promise); } + // Tracks whether this iteration handed an item to the consumer and resumed + // normally. Any other exit — internal throw, `done` return, or the consumer + // abandoning us via `.return()`/`.throw()` at the `yield` below — must close + // the upstream iterator so the underlying SSE body / SDK stream (and its + // socket) is released instead of being left suspended. + let continuing = false; try { const outcome = await Promise.race(racers); if (outcome.kind === "abort") { @@ -247,7 +256,9 @@ export async function* iterateWithIdleTimeout( lastProgressAt = Date.now(); } yield item; + continuing = true; } finally { + if (!continuing) closeIterator(); if (timer !== undefined) clearTimeout(timer); // Resolve dangling promises so the racers don't leak (Promise.race is one-shot). resolveTimeout?.({ kind: "timeout" }); diff --git a/packages/ai/src/utils/oauth/cerebras.ts b/packages/ai/src/utils/oauth/cerebras.ts deleted file mode 100644 index 5f66f1399..000000000 --- a/packages/ai/src/utils/oauth/cerebras.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** Cerebras login flow (API key paste against https://api.cerebras.ai/v1). */ -import { createApiKeyLogin } from "./api-key-login"; - -export const loginCerebras = createApiKeyLogin({ - providerLabel: "Cerebras", - authUrl: "https://cloud.cerebras.ai/platform/", - instructions: "Copy your API key from the Cerebras dashboard", - promptMessage: "Paste your Cerebras API key", - placeholder: "csk-...", - validation: { - kind: "chat-completions", - provider: "Cerebras", - baseUrl: "https://api.cerebras.ai/v1", - model: "gpt-oss-120b", - }, -}); diff --git a/packages/ai/src/utils/oauth/fireworks.ts b/packages/ai/src/utils/oauth/fireworks.ts deleted file mode 100644 index ece0f0751..000000000 --- a/packages/ai/src/utils/oauth/fireworks.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** Fireworks login flow (API key paste against https://api.fireworks.ai/inference/v1). */ -import { createApiKeyLogin } from "./api-key-login"; - -export const loginFireworks = createApiKeyLogin({ - providerLabel: "Fireworks", - authUrl: "https://app.fireworks.ai/settings/users/api-keys", - instructions: "Create or copy your Fireworks API key", - promptMessage: "Paste your Fireworks API key", - placeholder: "fw_...", - validation: { - kind: "models-endpoint", - provider: "Fireworks", - modelsUrl: "https://api.fireworks.ai/inference/v1/models", - }, -}); diff --git a/packages/ai/src/utils/oauth/index.ts b/packages/ai/src/utils/oauth/index.ts deleted file mode 100644 index c50ea90cf..000000000 --- a/packages/ai/src/utils/oauth/index.ts +++ /dev/null @@ -1,502 +0,0 @@ -// ============================================================================ -// High-level API -// ============================================================================ -import type { - OAuthCredentials, - OAuthProvider, - OAuthProviderId, - OAuthProviderInfo, - OAuthProviderInterface, -} from "./types"; - -const builtInOAuthProviders: OAuthProviderInfo[] = [ - // Most popular coding subscriptions / gateways. - { - id: "openai-codex", - name: "ChatGPT Plus/Pro (Codex Subscription)", - available: true, - }, - { - id: "anthropic", - name: "Anthropic (Claude Pro/Max)", - available: true, - }, - { - id: "zai", - name: "Z.AI (GLM Coding Plan)", - available: true, - }, - { - id: "kimi-code", - name: "Kimi Code", - available: true, - }, - { - id: "openrouter", - name: "OpenRouter", - available: true, - }, - // Other coding subscriptions & first-party assistants. - { - id: "github-copilot", - name: "GitHub Copilot", - available: true, - }, - { - id: "cursor", - name: "Cursor (Claude, GPT, etc.)", - available: true, - }, - { - id: "google-antigravity", - name: "Antigravity (Gemini 3, Claude, GPT-OSS)", - available: true, - }, - { - id: "google-gemini-cli", - name: "Google Cloud Code Assist (Gemini CLI)", - available: true, - }, - { - id: "openai-codex-device", - name: "ChatGPT Plus/Pro (Codex, headless/device)", - available: true, - }, - { - id: "xai-oauth", - name: "xAI Grok OAuth (SuperGrok Subscription)", - available: true, - }, - { - id: "gitlab-duo", - name: "GitLab Duo", - available: true, - }, - { - id: "alibaba-coding-plan", - name: "Alibaba Coding Plan", - available: true, - }, - { - id: "zhipu-coding-plan", - name: "Zhipu Coding Plan (智谱)", - available: true, - }, - { - id: "qwen-portal", - name: "Qwen Portal", - available: true, - }, - { - id: "minimax-code", - name: "MiniMax Coding Plan (International)", - available: true, - }, - { - id: "minimax-code-cn", - name: "MiniMax Coding Plan (China)", - available: true, - }, - { - id: "xiaomi", - name: "Xiaomi MiMo", - available: true, - }, - { - id: "xiaomi-token-plan-sgp", - name: "Xiaomi Token Plan (Singapore)", - available: true, - }, - { - id: "xiaomi-token-plan-ams", - name: "Xiaomi Token Plan (Europe)", - available: true, - }, - { - id: "xiaomi-token-plan-cn", - name: "Xiaomi Token Plan (China)", - available: true, - }, - { - id: "firepass", - name: "Fire Pass (Fireworks Kimi K2.6 Turbo subscription)", - available: true, - }, - { - id: "wafer-pass", - name: "Wafer Pass (flat-rate subscription)", - available: true, - }, - // Direct model-provider APIs (pay-as-you-go inference). - { - id: "deepseek", - name: "DeepSeek", - available: true, - }, - { - id: "moonshot", - name: "Moonshot (Kimi API)", - available: true, - }, - { - id: "cerebras", - name: "Cerebras", - available: true, - }, - { - id: "fireworks", - name: "Fireworks", - available: true, - }, - { - id: "together", - name: "Together", - available: true, - }, - { - id: "nvidia", - name: "NVIDIA", - available: true, - }, - { - id: "huggingface", - name: "Hugging Face Inference", - available: true, - }, - { - id: "perplexity", - name: "Perplexity (Pro/Max)", - available: true, - }, - { - id: "qianfan", - name: "Qianfan", - available: true, - }, - { - id: "venice", - name: "Venice", - available: true, - }, - { - id: "synthetic", - name: "Synthetic", - available: true, - }, - { - id: "nanogpt", - name: "NanoGPT", - available: true, - }, - { - id: "wafer-serverless", - name: "Wafer Serverless (pay-as-you-go)", - available: true, - }, - // Aggregator gateways / routers. - { - id: "vercel-ai-gateway", - name: "Vercel AI Gateway", - available: true, - }, - { - id: "cloudflare-ai-gateway", - name: "Cloudflare AI Gateway", - available: true, - }, - { - id: "litellm", - name: "LiteLLM", - available: true, - }, - { - id: "kilo", - name: "Kilo Gateway", - available: true, - }, - { - id: "zenmux", - name: "ZenMux", - available: true, - }, - { - id: "opencode-zen", - name: "OpenCode Zen", - available: true, - }, - { - id: "opencode-go", - name: "OpenCode Go", - available: true, - }, - // Search & tool providers. - { - id: "tavily", - name: "Tavily", - available: true, - }, - { - id: "kagi", - name: "Kagi", - available: true, - }, - { - id: "parallel", - name: "Parallel", - available: true, - }, - // Local runtimes. - { - id: "ollama", - name: "Ollama (Local OpenAI-compatible)", - available: true, - }, - { - id: "ollama-cloud", - name: "Ollama Cloud", - available: true, - }, - { - id: "lm-studio", - name: "LM Studio (Local OpenAI-compatible)", - available: true, - }, - { - id: "vllm", - name: "vLLM (Local OpenAI-compatible)", - available: true, - }, -]; - -const customOAuthProviders = new Map(); - -/** - * Register a custom OAuth provider. - */ -export function registerOAuthProvider(provider: OAuthProviderInterface): void { - customOAuthProviders.set(provider.id, provider); -} - -/** - * Get a custom OAuth provider by ID. - */ -export function getOAuthProvider(id: OAuthProviderId): OAuthProviderInterface | undefined { - return customOAuthProviders.get(id); -} - -/** - * Remove all custom OAuth providers registered by a source. - */ -export function unregisterOAuthProviders(sourceId: string): void { - for (const [id, provider] of customOAuthProviders.entries()) { - if (provider.sourceId === sourceId) { - customOAuthProviders.delete(id); - } - } -} - -/** - * Refresh token for any OAuth provider. - * Saves the new credentials and returns the new access token. - */ -export async function refreshOAuthToken( - provider: OAuthProvider, - credentials: OAuthCredentials, -): Promise { - if (!credentials) { - throw new Error(`No OAuth credentials found for ${provider}`); - } - - let newCredentials: OAuthCredentials; - switch (provider) { - case "anthropic": { - const { refreshAnthropicToken } = await import("./anthropic"); - newCredentials = await refreshAnthropicToken(credentials.refresh); - break; - } - case "github-copilot": { - const { refreshGitHubCopilotToken } = await import("./github-copilot"); - newCredentials = await refreshGitHubCopilotToken(credentials.refresh, credentials.enterpriseUrl); - break; - } - case "google-gemini-cli": { - const { refreshGoogleCloudToken } = await import("./google-gemini-cli"); - if (!credentials.projectId) { - throw new Error("Google Cloud credentials missing projectId"); - } - newCredentials = await refreshGoogleCloudToken(credentials.refresh, credentials.projectId); - break; - } - case "google-antigravity": { - const { refreshAntigravityToken } = await import("./google-antigravity"); - if (!credentials.projectId) { - throw new Error("Antigravity credentials missing projectId"); - } - newCredentials = await refreshAntigravityToken(credentials.refresh, credentials.projectId); - break; - } - case "openai-codex": - case "openai-codex-device": { - const { refreshOpenAICodexToken } = await import("./openai-codex"); - newCredentials = await refreshOpenAICodexToken(credentials.refresh); - break; - } - case "kimi-code": { - const { refreshKimiToken } = await import("./kimi"); - newCredentials = await refreshKimiToken(credentials.refresh); - break; - } - case "gitlab-duo": { - const { refreshGitLabDuoToken } = await import("./gitlab-duo"); - newCredentials = await refreshGitLabDuoToken(credentials); - break; - } - case "cursor": { - const { refreshCursorToken } = await import("./cursor"); - newCredentials = await refreshCursorToken(credentials.refresh); - break; - } - case "xai-oauth": { - const { refreshXAIOAuthToken } = await import("./xai-oauth"); - newCredentials = await refreshXAIOAuthToken(credentials.refresh); - break; - } - case "kilo": - case "perplexity": - case "huggingface": - case "opencode-zen": - case "opencode-go": - case "openrouter": - case "cerebras": - case "fireworks": - case "firepass": - case "nvidia": - case "nanogpt": - case "synthetic": - case "together": - case "litellm": - case "lm-studio": - case "ollama": - case "ollama-cloud": - case "xiaomi": - case "xiaomi-token-plan-sgp": - case "xiaomi-token-plan-ams": - case "xiaomi-token-plan-cn": - case "zai": - case "zhipu-coding-plan": - case "qianfan": - case "venice": - case "minimax-code": - case "minimax-code-cn": - case "moonshot": - case "kagi": - case "cloudflare-ai-gateway": - case "vercel-ai-gateway": - case "qwen-portal": - case "wafer-pass": - case "wafer-serverless": - case "zenmux": - case "vllm": - // API keys / static bearer tokens don't expire, return as-is - newCredentials = credentials; - break; - default: - throw new Error(`Unknown OAuth provider: ${provider}`); - } - return newCredentials; -} -function getPerplexityJwtExpiryMs(token: string): number | undefined { - const parts = token.split("."); - if (parts.length !== 3) return undefined; - const payload = parts[1]; - if (!payload) return undefined; - try { - const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { exp?: unknown }; - if (typeof decoded.exp !== "number" || !Number.isFinite(decoded.exp)) return undefined; - return decoded.exp * 1000 - 5 * 60_000; - } catch { - return undefined; - } -} - -/** - * Build API-key bytes for a provider from an already-fresh OAuth credential. - * - * Refresh is owned by AuthStorage. This helper deliberately refuses expired - * credentials so it cannot POST broker redaction sentinels to upstream token - * endpoints as a side channel. - * - * For providers that need credential metadata at request time, returns - * JSON-encoded credentials plus expiry metadata for diagnostics/edge guards. - * @returns API key string, or null if no credentials - * @throws Error if the credential is expired and must be refreshed upstream - */ -export async function getOAuthApiKey( - provider: OAuthProvider, - credentials: Record, -): Promise<{ newCredentials: OAuthCredentials; apiKey: string } | null> { - let creds = credentials[provider]; - if (!creds) { - return null; - } - - if (provider === "perplexity") { - // Perplexity JWTs usually omit `exp` (server-side sessions). Trust the JWT - // claim when present; otherwise treat the credential as non-expiring rather - // than honoring a stale stored `expires` (older logins wrote loginTime+1h). - const NEVER_EXPIRES = 8.64e15; - const normalizedExpires = - creds.expires > 0 && creds.expires < 10_000_000_000 ? creds.expires * 1000 : creds.expires; - const jwtExpiry = getPerplexityJwtExpiryMs(creds.access); - const expires = jwtExpiry ?? Math.max(normalizedExpires, NEVER_EXPIRES); - if (expires !== creds.expires) { - creds = { ...creds, expires }; - } - } - // Refresh is the sole responsibility of `AuthStorage` (which calls - // `refreshOAuthToken` directly with broker-aware single-flighting). If we - // reach here with an expired credential, the outer pipeline failed to - // refresh before this call OR the refresh slot is the broker sentinel — - // either way, posting the credential to a provider endpoint would only - // trigger a `__remote__`-against-real-provider failure that gets classified - // as `invalid_grant` and disables the row. Refuse loudly instead. - if (Date.now() >= creds.expires) { - if (provider === "perplexity") { - const jwtExpiry = getPerplexityJwtExpiryMs(creds.access); - if (jwtExpiry && Date.now() < jwtExpiry) { - const fallbackCredentials = { ...creds, expires: jwtExpiry }; - return { newCredentials: fallbackCredentials, apiKey: fallbackCredentials.access }; - } - } - throw new Error( - `OAuth credential for ${provider} is expired and must be refreshed via AuthStorage before getOAuthApiKey is called`, - ); - } - // For providers that need request-time credential metadata, return JSON. - const needsStructuredApiKey = - provider === "github-copilot" || provider === "google-gemini-cli" || provider === "google-antigravity"; - const apiKey = needsStructuredApiKey - ? JSON.stringify({ - token: creds.access, - enterpriseUrl: creds.enterpriseUrl, - projectId: creds.projectId, - refreshToken: creds.refresh, - expiresAt: creds.expires, - email: creds.email, - accountId: creds.accountId, - }) - : creds.access; - return { newCredentials: creds, apiKey }; -} - -/** - * Get list of OAuth providers. - */ -export function getOAuthProviders(): OAuthProviderInfo[] { - const customProviders = Array.from(customOAuthProviders.values(), provider => ({ - id: provider.id, - name: provider.name, - available: true, - })); - return [...builtInOAuthProviders, ...customProviders]; -} diff --git a/packages/ai/src/utils/oauth/lm-studio.ts b/packages/ai/src/utils/oauth/lm-studio.ts deleted file mode 100644 index d1e3921fe..000000000 --- a/packages/ai/src/utils/oauth/lm-studio.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * LM Studio login flow. - * - * LM Studio provides an OpenAI-compatible API at a local base URL. - * It usually runs unauthenticated but can be configured to require a bearer token. - * - * This flow stores an API-key-style credential used by `/login` and auth storage. - */ - -import type { OAuthController, OAuthProvider } from "./types"; - -const PROVIDER_ID: OAuthProvider = "lm-studio"; -export const DEFAULT_LOCAL_TOKEN = "lm-studio-local"; - -/** - * Login to LM Studio. - * - * Opens LM Studio API docs, prompts for an optional token, - * and returns a stored key value. - */ -export async function loginLmStudio(options: OAuthController): Promise { - if (!options.onPrompt) { - throw new Error(`${PROVIDER_ID} login requires onPrompt callback`); - } - - const apiKey = await options.onPrompt({ - message: "Optional: Paste LM Studio API key (to customize endpoint URL, set LM_STUDIO_BASE_URL env var)", - placeholder: DEFAULT_LOCAL_TOKEN, - allowEmpty: true, - }); - - if (options.signal?.aborted) { - throw new Error("Login cancelled"); - } - - const trimmed = apiKey.trim(); - return trimmed || DEFAULT_LOCAL_TOKEN; -} diff --git a/packages/ai/src/utils/oauth/moonshot.ts b/packages/ai/src/utils/oauth/moonshot.ts deleted file mode 100644 index b825b9273..000000000 --- a/packages/ai/src/utils/oauth/moonshot.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Moonshot login flow (API key paste against https://api.moonshot.ai/v1). - * - * Validation hits `GET /v1/models` rather than a chat completion. Moonshot's - * thinking models (e.g. kimi-k2.5/k2.6) reject the `temperature: 0` probe used - * by the chat-completions validator with `invalid temperature: only 1 is - * allowed for this model`, so a hello-world chat call cannot authenticate the - * key reliably across the Moonshot catalog. - */ -import { createApiKeyLogin } from "./api-key-login"; - -export const loginMoonshot = createApiKeyLogin({ - providerLabel: "Moonshot", - authUrl: "https://platform.moonshot.ai/console/api-keys", - instructions: "Copy your API key from the Moonshot dashboard", - promptMessage: "Paste your Moonshot API key", - placeholder: "sk-...", - validation: { - kind: "models-endpoint", - provider: "moonshot", - modelsUrl: "https://api.moonshot.ai/v1/models", - }, -}); diff --git a/packages/ai/src/utils/oauth/nanogpt.ts b/packages/ai/src/utils/oauth/nanogpt.ts deleted file mode 100644 index 7f499465d..000000000 --- a/packages/ai/src/utils/oauth/nanogpt.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** NanoGPT login flow (API key paste, validated via /models). */ -import { createApiKeyLogin } from "./api-key-login"; - -export const loginNanoGPT = createApiKeyLogin({ - providerLabel: "NanoGPT", - authUrl: "https://nano-gpt.com/api", - instructions: "Create or copy your NanoGPT API key", - promptMessage: "Paste your NanoGPT API key", - placeholder: "sk-...", - validation: { - kind: "models-endpoint", - provider: "NanoGPT", - modelsUrl: "https://nano-gpt.com/api/v1/models", - }, -}); diff --git a/packages/ai/src/utils/oauth/synthetic.ts b/packages/ai/src/utils/oauth/synthetic.ts deleted file mode 100644 index 5b2aaeeb0..000000000 --- a/packages/ai/src/utils/oauth/synthetic.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** Synthetic login flow (API key paste against https://api.synthetic.new/openai/v1). */ -import { createApiKeyLogin } from "./api-key-login"; - -export const loginSynthetic = createApiKeyLogin({ - providerLabel: "Synthetic", - authUrl: "https://dev.synthetic.new/docs/api/overview", - instructions: "Copy your API key from the Synthetic dashboard", - promptMessage: "Paste your Synthetic API key", - placeholder: "sk-...", - validation: { - kind: "models-endpoint", - provider: "Synthetic", - modelsUrl: "https://api.synthetic.new/openai/v1/models", - }, -}); diff --git a/packages/ai/src/utils/oauth/together.ts b/packages/ai/src/utils/oauth/together.ts deleted file mode 100644 index 03bc5af43..000000000 --- a/packages/ai/src/utils/oauth/together.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** Together login flow (API key paste against https://api.together.xyz/v1). */ -import { createApiKeyLogin } from "./api-key-login"; - -export const loginTogether = createApiKeyLogin({ - providerLabel: "Together", - authUrl: "https://api.together.xyz/settings/api-keys", - instructions: "Copy your API key from the Together dashboard", - promptMessage: "Paste your Together API key", - placeholder: "sk-...", - validation: { - kind: "chat-completions", - provider: "together", - baseUrl: "https://api.together.xyz/v1", - model: "moonshotai/Kimi-K2.5", - }, -}); diff --git a/packages/ai/src/utils/oauth/zenmux.ts b/packages/ai/src/utils/oauth/zenmux.ts deleted file mode 100644 index 4cbd8d575..000000000 --- a/packages/ai/src/utils/oauth/zenmux.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** ZenMux login flow (API key paste, validated via /models). */ -import { createApiKeyLogin } from "./api-key-login"; - -export const loginZenMux = createApiKeyLogin({ - providerLabel: "ZenMux", - authUrl: "https://zenmux.ai/settings/keys", - instructions: "Create or copy your ZenMux API key", - promptMessage: "Paste your ZenMux API key", - placeholder: "sk-...", - validation: { - kind: "models-endpoint", - provider: "ZenMux", - modelsUrl: "https://zenmux.ai/api/v1/models", - }, -}); diff --git a/packages/ai/test/abort-source-tracker.test.ts b/packages/ai/test/abort-source-tracker.test.ts index 6500795cd..d9b0b9def 100644 --- a/packages/ai/test/abort-source-tracker.test.ts +++ b/packages/ai/test/abort-source-tracker.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { createAbortSourceTracker } from "../src/utils/abort"; +import { createAbortSourceTracker } from "@oh-my-pi/pi-ai/utils/abort"; /** * Defends the contract `AssistantMessageEventStream` providers depend on: caller diff --git a/packages/ai/test/abortable-iterator.test.ts b/packages/ai/test/abortable-iterator.test.ts index ed95a9879..4afbeb3b5 100644 --- a/packages/ai/test/abortable-iterator.test.ts +++ b/packages/ai/test/abortable-iterator.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { iterateUntilAbort } from "../src/utils/abortable-iterator"; +import { iterateUntilAbort } from "@oh-my-pi/pi-ai/utils/abortable-iterator"; function makeSource(handlers: { next: () => Promise>; onReturn?: () => void }): AsyncIterable { return { diff --git a/packages/ai/test/anthropic-alignment.test.ts b/packages/ai/test/anthropic-alignment.test.ts index 6ccc00eda..d6346815e 100644 --- a/packages/ai/test/anthropic-alignment.test.ts +++ b/packages/ai/test/anthropic-alignment.test.ts @@ -13,6 +13,7 @@ import { claudeCodeSystemInstruction, claudeCodeVersion, claudeToolPrefix, + deriveClaudeDeviceId, generateClaudeCloakingUserId, isClaudeCloakingUserId, mapStainlessArch, @@ -54,7 +55,7 @@ function createAbortedSignal(): AbortSignal { type CaptureAnthropicOptions = { isOAuth?: boolean; - metadata?: { user_id?: string }; + metadata?: { user_id?: string; account_uuid?: string; accountId?: string; account_id?: string }; thinkingEnabled?: boolean; reasoning?: Effort; temperature?: number; @@ -184,12 +185,12 @@ describe("Anthropic request fingerprint alignment", () => { expect(hiddenUtility.defaultHeaders["anthropic-beta"]).toContain("redact-thinking-2026-02-12"); }); - it("matches CC system-block layout: billing and instruction uncached, context cached in order", () => { + it("matches CC system-block layout: billing and instruction uncached, single breakpoint on the last context block", () => { // We mimic Claude Code's billing+instruction system layout but do NOT emit // the `scope: "global"` field that CC attaches to its middle breakpoint — // `prompt-caching-scope-2026-01-05` only works against canonical // `api.anthropic.com`, and third-party Anthropic-compatible proxies - // (z.ai, openrouter, g0i, …) reject the unknown field outright. + // (z.ai, openrouter, …) reject the unknown field outright. const blocks = buildAnthropicSystemBlocks(["Stay concise."], { includeClaudeCodeInstruction: true, extraInstructions: ["Use citations when possible"], @@ -201,10 +202,12 @@ describe("Anthropic request fingerprint alignment", () => { expect(blocks?.[0].cache_control).toBeUndefined(); expect(blocks?.[1].text).toBe(claudeCodeSystemInstruction); expect(blocks?.[1].cache_control).toBeUndefined(); + // Only the LAST system block carries the cache breakpoint: a single trailing + // `cache_control` caches the entire system prefix as one entry, conserving the + // 4-breakpoint budget (`enforceCacheControlLimit`) for message-level caching. expect(blocks?.[2]).toEqual({ type: "text", text: "Use citations when possible", - cache_control: { type: "ephemeral" }, }); expect(blocks?.[3]).toEqual({ type: "text", @@ -235,6 +238,50 @@ describe("Anthropic request fingerprint alignment", () => { }); }); + it("caches tool-result-only user messages in OAuth request payloads", async () => { + const payload = (await captureAnthropicPayload(ANTHROPIC_MODEL, { + systemPrompt: ["Stay concise."], + messages: [ + { role: "user", content: "Use the tool", timestamp: Date.now() }, + { + role: "assistant", + content: [{ type: "toolCall", id: "tool-1", name: "lookup", arguments: {} }], + api: "anthropic-messages", + provider: "anthropic", + model: ANTHROPIC_MODEL.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: Date.now(), + }, + { + role: "toolResult", + toolCallId: "tool-1", + toolName: "lookup", + content: [{ type: "text", text: "large tool output" }], + details: {}, + isError: false, + timestamp: Date.now(), + }, + ], + })) as { messages?: Array<{ content?: Array<{ type?: string; cache_control?: unknown }> | string }> }; + + const messages = payload.messages ?? []; + const lastContent = messages[messages.length - 1]?.content; + expect(Array.isArray(lastContent)).toBe(true); + expect(Array.isArray(lastContent) ? lastContent[0]?.type : undefined).toBe("tool_result"); + expect(Array.isArray(lastContent) ? lastContent[0]?.cache_control : undefined).toEqual({ + type: "ephemeral", + ttl: "1h", + }); + }); + it("clamps requested max_tokens to Claude Code's 64k cap when the model ceiling is higher", async () => { const payload = (await captureAnthropicPayload( { ...ANTHROPIC_MODEL, id: "claude-opus-4-8", name: "Claude Opus 4.8", maxTokens: 128_000 }, @@ -376,6 +423,18 @@ describe("Anthropic request fingerprint alignment", () => { expect(isClaudeCloakingUserId(userId)).toBe(true); }); + it("scopes derived Claude device IDs to the account when known", () => { + const installId = "test-install-id"; + const accountId = "12345678-1234-1234-1234-1234567890ab"; + const otherAccountId = "abcdefab-cdef-abcd-efab-cdefabcdef12"; + const deviceId = deriveClaudeDeviceId(installId, accountId); + + expect(deviceId).toMatch(/^[0-9a-f]{64}$/); + expect(deviceId).toBe(deriveClaudeDeviceId(installId, accountId)); + expect(deviceId).not.toBe(deriveClaudeDeviceId(installId, otherAccountId)); + expect(deviceId).not.toBe(deriveClaudeDeviceId(installId)); + }); + it("injects Claude Code JSON metadata.user_id for OAuth requests when missing", async () => { const payload = (await captureAnthropicPayload(ANTHROPIC_MODEL, { systemPrompt: ["Stay concise."], @@ -407,6 +466,38 @@ describe("Anthropic request fingerprint alignment", () => { expect(firstUserId.device_id).toBe(secondUserId.device_id); }); + it("uses metadata account_uuid when generating OAuth device_id", async () => { + const sessionId = "167ec5b4-e711-4169-879f-84fa52679d9c"; + const accountId = "12345678-1234-1234-1234-1234567890ab"; + const otherAccountId = "abcdefab-cdef-abcd-efab-cdefabcdef12"; + const first = (await captureAnthropicPayload( + ANTHROPIC_MODEL, + { + systemPrompt: ["Stay concise."], + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { metadata: { account_uuid: accountId }, sessionId }, + )) as { metadata?: { user_id?: string } }; + const second = (await captureAnthropicPayload( + ANTHROPIC_MODEL, + { + systemPrompt: ["Stay concise."], + messages: [{ role: "user", content: "Hi again", timestamp: Date.now() }], + }, + { metadata: { account_uuid: otherAccountId }, sessionId }, + )) as { metadata?: { user_id?: string } }; + const firstUserId = JSON.parse(first.metadata?.user_id ?? "{}") as { account_uuid?: string; device_id?: string }; + const secondUserId = JSON.parse(second.metadata?.user_id ?? "{}") as { + account_uuid?: string; + device_id?: string; + }; + + expect(firstUserId.account_uuid).toBe(accountId); + expect(secondUserId.account_uuid).toBe(otherAccountId); + expect(firstUserId.device_id).toMatch(/^[0-9a-f]{64}$/); + expect(firstUserId.device_id).not.toBe(secondUserId.device_id); + }); + it("uses the explicit session id for generated OAuth metadata", async () => { const sessionId = "167ec5b4-e711-4169-879f-84fa52679d9c"; const payload = (await captureAnthropicPayload( @@ -1307,6 +1398,22 @@ describe("Anthropic request fingerprint alignment", () => { expect(payload.thinking).toEqual({ type: "disabled" }); }); + it("keeps sampling params when reasoning is explicitly disabled", async () => { + const payload = (await captureAnthropicPayload( + ANTHROPIC_MODEL, + { + systemPrompt: ["Stay concise."], + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { thinkingEnabled: false, temperature: 0.2, topP: 0.3, topK: 4 }, + )) as { thinking?: { type?: string }; temperature?: number; top_p?: number; top_k?: number }; + + expect(payload.thinking).toEqual({ type: "disabled" }); + expect(payload.temperature).toBe(0.2); + expect(payload.top_p).toBe(0.3); + expect(payload.top_k).toBe(4); + }); + it("drops temperature and sampling params for Opus 4.7 without enabled thinking", async () => { const payload = (await captureAnthropicPayload( { ...ANTHROPIC_MODEL, id: "claude-opus-4-7", name: "Claude Opus 4.7" }, @@ -1528,8 +1635,15 @@ describe("Anthropic request fingerprint alignment", () => { const name = "Read"; const prefixed = applyClaudeToolPrefix(name); expect(prefixed).toBe(`${claudeToolPrefix}${name}`); - expect(applyClaudeToolPrefix(prefixed)).toBe(prefixed); // idempotent expect(stripClaudeToolPrefix(prefixed)).toBe(name); // roundtrip + + // The prefix codec is injective, NOT idempotent: an internal tool name that + // already starts with the prefix gets a second one so it survives the return + // trip. Skipping it would strip a real leading underscore and lose the tool. + const underscored = `${claudeToolPrefix}foo`; + const underscoredWire = applyClaudeToolPrefix(underscored); + expect(underscoredWire).toBe(`${claudeToolPrefix}${underscored}`); + expect(stripClaudeToolPrefix(underscoredWire)).toBe(underscored); }); }); diff --git a/packages/ai/test/anthropic-client.test.ts b/packages/ai/test/anthropic-client.test.ts index 64eb086f3..259b13b8a 100644 --- a/packages/ai/test/anthropic-client.test.ts +++ b/packages/ai/test/anthropic-client.test.ts @@ -3,8 +3,8 @@ import { AnthropicApiError, AnthropicConnectionTimeoutError, AnthropicMessagesClient, -} from "../src/providers/anthropic-client"; -import type { MessageCreateParamsStreaming } from "../src/providers/anthropic-wire"; +} from "@oh-my-pi/pi-ai/providers/anthropic-client"; +import type { MessageCreateParamsStreaming } from "@oh-my-pi/pi-ai/providers/anthropic-wire"; const params: MessageCreateParamsStreaming = { model: "claude-sonnet-4-5", diff --git a/packages/ai/test/anthropic-many-image-resize.test.ts b/packages/ai/test/anthropic-many-image-resize.test.ts index 91b13effd..493f34464 100644 --- a/packages/ai/test/anthropic-many-image-resize.test.ts +++ b/packages/ai/test/anthropic-many-image-resize.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { streamAnthropic } from "../src/providers/anthropic"; -import type { AssistantMessage, Context, ImageContent, Model, TextContent, Usage } from "../src/types"; +import { streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic"; +import type { AssistantMessage, Context, ImageContent, Model, TextContent, Usage } from "@oh-my-pi/pi-ai/types"; const RED_1X1_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC"; diff --git a/packages/ai/test/anthropic-oauth.test.ts b/packages/ai/test/anthropic-oauth.test.ts index 39c6be5f5..6b6e91c50 100644 --- a/packages/ai/test/anthropic-oauth.test.ts +++ b/packages/ai/test/anthropic-oauth.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { buildAnthropicAuthConfig, buildAnthropicSearchHeaders, buildAnthropicUrl } from "../src/utils/anthropic-auth"; -import { AnthropicOAuthFlow, refreshAnthropicToken } from "../src/utils/oauth/anthropic"; +import { AnthropicOAuthFlow, refreshAnthropicToken } from "@oh-my-pi/pi-ai/registry/oauth/anthropic"; +import { + buildAnthropicAuthConfig, + buildAnthropicSearchHeaders, + buildAnthropicUrl, +} from "@oh-my-pi/pi-ai/utils/anthropic-auth"; import { withEnv } from "./helpers"; const originalFetch = global.fetch; diff --git a/packages/ai/test/anthropic-stream-envelope.test.ts b/packages/ai/test/anthropic-stream-envelope.test.ts index dfe5d5c50..006e96839 100644 --- a/packages/ai/test/anthropic-stream-envelope.test.ts +++ b/packages/ai/test/anthropic-stream-envelope.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import { scheduler } from "node:timers/promises"; -import { streamAnthropic } from "../src/providers/anthropic"; -import { AnthropicMessages } from "../src/providers/anthropic-client"; -import type { AssistantMessageEvent, Context, Model, ProviderSessionState } from "../src/types"; +import { streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic"; +import { AnthropicMessages } from "@oh-my-pi/pi-ai/providers/anthropic-client"; +import type { AssistantMessageEvent, Context, Model, ProviderSessionState } from "@oh-my-pi/pi-ai/types"; const model: Model<"anthropic-messages"> = { id: "claude-sonnet-4-5", @@ -34,6 +34,18 @@ const cityObjectSchema = { type MockAnthropicEvent = Record; type MockAnthropicStream = AsyncIterable; + +// Provider session state is keyed per endpoint+model (`anthropic-messages:\0`), +// with a legacy unscoped `anthropic-messages` key still honored. Look up the strict-tools +// flag without depending on the exact key shape. +function anthropicStrictToolsDisabled(map: Map): boolean | undefined { + for (const [key, value] of map) { + if (key === "anthropic-messages" || key.startsWith("anthropic-messages:")) { + return (value as { strictToolsDisabled?: boolean }).strictToolsDisabled; + } + } + return undefined; +} type MockAnthropicRequest = { withResponse(): Promise<{ data: MockAnthropicStream; @@ -199,6 +211,43 @@ function createMalformedToolUseEvents(): MockAnthropicEvent[] { ]; } +function createUnterminatedToolUseSplicedReconnectEvents(): MockAnthropicEvent[] { + return [ + { + type: "message_start", + message: { + id: "msg_tool_truncated", + usage: { input_tokens: 12, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + }, + }, + // Tool call begins streaming but the transport drops before any argument + // bytes — and before `content_block_stop` — arrive, so `arguments` is still + // the seed `{}`. + { + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "tool_truncated", name: "lookup_weather", input: {} }, + }, + { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: "" } }, + // A transparent reconnect splices a fresh message envelope onto the same + // stream. The duplicate `message_start` is deduped, but the orphaned tool + // block above is never closed and the reconnect supplies the terminal stop. + { + type: "message_start", + message: { + id: "msg_reconnect", + usage: { input_tokens: 12, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + }, + }, + { + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { input_tokens: 12, output_tokens: 4, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + }, + { type: "message_stop" }, + ]; +} + function countEvents(events: AssistantMessageEvent[], type: AssistantMessageEvent["type"]): number { return events.filter(event => event.type === type).length; } @@ -281,6 +330,62 @@ describe("anthropic stream envelope handling", () => { expect(result.content).toEqual([{ type: "text", text: "hello" }]); }); + it("ignores unknown content block envelopes while preserving known blocks", async () => { + const events: MockAnthropicEvent[] = [ + { + type: "message_start", + message: { + id: "msg_unknown_block", + usage: { + input_tokens: 12, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }, + }, + { + type: "content_block_start", + index: 0, + content_block: { type: "server_tool_use", id: "srv_1", name: "web_search" }, + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: '{"query":"weather"}' }, + }, + { type: "content_block_stop", index: 0 }, + { type: "content_block_start", index: 1, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "hello" } }, + { type: "content_block_stop", index: 1 }, + { + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { + input_tokens: 12, + output_tokens: 4, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }, + { type: "message_stop" }, + ]; + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation(() => createMockRequest(events) as never); + + const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); + const observed: AssistantMessageEvent[] = []; + for await (const event of stream) { + observed.push(event); + } + const result = await stream.result(); + + expect(countEvents(observed, "error")).toBe(0); + expect(countEvents(observed, "done")).toBe(1); + expect(result.stopReason).toBe("stop"); + expect(result.responseId).toBe("msg_unknown_block"); + expect(result.content).toEqual([{ type: "text", text: "hello" }]); + }); + it("retries malformed envelopes before content starts without duplicating streamed text events", async () => { let attempt = 0; vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation(() => { @@ -344,10 +449,7 @@ describe("anthropic stream envelope handling", () => { expect(countEvents(events, "done")).toBe(1); expect(countEvents(events, "error")).toBe(0); expect(strictFlags).toEqual([[true], [false]]); - expect( - (providerSessionState.get("anthropic-messages") as { strictToolsDisabled?: boolean } | undefined) - ?.strictToolsDisabled, - ).toBe(true); + expect(anthropicStrictToolsDisabled(providerSessionState)).toBe(true); const nextStream = streamAnthropic(model, toolContext, { apiKey: "sk-ant-test", providerSessionState }); const nextEvents: AssistantMessageEvent[] = []; @@ -397,13 +499,10 @@ describe("anthropic stream envelope handling", () => { expect(countEvents(events, "error")).toBe(1); expect(countEvents(events, "done")).toBe(0); expect(strictFlags).toEqual([[true]]); - expect( - (providerSessionState.get("anthropic-messages") as { strictToolsDisabled?: boolean } | undefined) - ?.strictToolsDisabled, - ).toBe(false); + expect(anthropicStrictToolsDisabled(providerSessionState)).toBe(false); }); - it("does not retry malformed envelopes after partial tool-call content starts streaming", async () => { + it("finalizes a tool call with malformed argument JSON as best-effort content instead of erroring", async () => { let attempt = 0; vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation(() => { attempt += 1; @@ -421,18 +520,50 @@ describe("anthropic stream envelope handling", () => { expect(countEvents(events, "toolcall_start")).toBe(1); expect(countEvents(events, "toolcall_delta")).toBe(1); expect(countEvents(events, "toolcall_end")).toBe(1); - expect(countEvents(events, "error")).toBe(1); - expect(countEvents(events, "done")).toBe(0); - expect(result.stopReason).toBe("error"); - expect(result.errorMessage).toContain("stream ended before terminal stop signal"); + expect(countEvents(events, "error")).toBe(0); + expect(countEvents(events, "done")).toBe(1); + expect(result.stopReason).toBe("stop"); const toolCall = result.content[0]; expect(toolCall?.type).toBe("toolCall"); if (toolCall?.type !== "toolCall") { - throw new Error("Expected toolCall content in terminal error payload"); + throw new Error("Expected toolCall content in degraded payload"); } + // Best-effort arguments recovered by the throttled streaming parser are retained. + expect(toolCall.arguments).toEqual({ city: "Par" }); expect("partialJson" in toolCall).toBe(false); }); + + it("finalizes a tool call left open by a spliced reconnect instead of erroring", async () => { + let attempt = 0; + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation(() => { + attempt += 1; + return createMockRequest(createUnterminatedToolUseSplicedReconnectEvents()) as never; + }); + + const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); + const events: AssistantMessageEvent[] = []; + for await (const event of stream) { + events.push(event); + } + const result = await stream.result(); + + // Non-fatal: the unterminated tool block is finalized with its seed `{}` arguments and the + // turn completes rather than erroring. Downstream argument validation handles the incomplete call. + expect(attempt).toBe(1); + expect(countEvents(events, "toolcall_start")).toBe(1); + expect(countEvents(events, "toolcall_end")).toBe(1); + expect(countEvents(events, "done")).toBe(1); + expect(countEvents(events, "error")).toBe(0); + expect(result.stopReason).toBe("stop"); + + const toolCall = result.content[0]; + expect(toolCall?.type).toBe("toolCall"); + if (toolCall?.type !== "toolCall") { + throw new Error("Expected toolCall content in degraded payload"); + } + expect(toolCall.arguments).toEqual({}); + }); it("parses raw SSE directly so unknown events do not fail Anthropic streams", async () => { vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation( () => @@ -456,7 +587,7 @@ describe("anthropic stream envelope handling", () => { expect(result.content).toEqual([{ type: "text", text: "hello" }]); }); - it("surfaces an error when a raw SSE stream closes before message_stop", async () => { + it("degrades to best-effort content when a raw SSE stream closes before message_stop", async () => { const incompleteFrames = createTextSuccessSseFrames("partial").filter( frame => !frame.includes("event: message_stop"), ); @@ -471,14 +602,13 @@ describe("anthropic stream envelope handling", () => { } const result = await stream.result(); - expect(countEvents(events, "error")).toBe(1); - expect(countEvents(events, "done")).toBe(0); - expect(result.stopReason).toBe("error"); - expect(result.errorMessage).toContain("stream ended before message_stop"); + expect(countEvents(events, "error")).toBe(0); + expect(countEvents(events, "done")).toBe(1); + expect(result.stopReason).toBe("stop"); expect(result.content).toEqual([{ type: "text", text: "partial" }]); }); - it("repairs malformed JSON in raw SSE event data before parsing", async () => { + it("skips malformed raw SSE event frames and degrades to best-effort content", async () => { const malformedTextDelta = '{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"line\\qbreak"}}'; const successEvents = createTextSuccessEvents("unused"); @@ -493,13 +623,18 @@ describe("anthropic stream envelope handling", () => { vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation(() => createRawSseRequest(frames) as never); const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); - for await (const _ of stream) { - // drain stream + const events: AssistantMessageEvent[] = []; + for await (const event of stream) { + events.push(event); } const result = await stream.result(); + // The unparseable content_block_delta frame is dropped; the surrounding text block streams + // empty and the turn completes normally. + expect(countEvents(events, "error")).toBe(0); + expect(countEvents(events, "done")).toBe(1); expect(result.stopReason).toBe("stop"); - expect(result.content).toEqual([{ type: "text", text: "line\\qbreak" }]); + expect(result.content).toEqual([{ type: "text", text: "" }]); }); it("surfaces a refusal fallback message when stop_details is null", async () => { const refusalEvents: MockAnthropicEvent[] = [ diff --git a/packages/ai/test/anthropic-stream-timeout.test.ts b/packages/ai/test/anthropic-stream-timeout.test.ts index 992fe5e59..422a94235 100644 --- a/packages/ai/test/anthropic-stream-timeout.test.ts +++ b/packages/ai/test/anthropic-stream-timeout.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { streamAnthropic } from "../src/providers/anthropic"; -import type { AnthropicMessagesClientLike } from "../src/providers/anthropic-client"; -import type { Context, Model } from "../src/types"; +import { streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic"; +import type { AnthropicMessagesClientLike } from "@oh-my-pi/pi-ai/providers/anthropic-client"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; import { waitForDelayOrAbort } from "./helpers"; const originalFetch = global.fetch; @@ -86,6 +86,7 @@ function createSuccessfulAnthropicEvents(text: string): MockAnthropicEvent[] { cache_creation_input_tokens: 0, }, }, + { type: "message_stop" }, ]; } diff --git a/packages/ai/test/api-registry.test.ts b/packages/ai/test/api-registry.test.ts index f62840076..5491de1a4 100644 --- a/packages/ai/test/api-registry.test.ts +++ b/packages/ai/test/api-registry.test.ts @@ -5,8 +5,8 @@ import { getCustomApi, registerCustomApi, unregisterCustomApis, -} from "../src/api-registry"; -import type { AssistantMessageEventStream } from "../src/types"; +} from "@oh-my-pi/pi-ai/api-registry"; +import type { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/types"; afterEach(() => { clearCustomApis(); diff --git a/packages/ai/test/auth-broker-refresher.test.ts b/packages/ai/test/auth-broker-refresher.test.ts index 52e192963..82787da9b 100644 --- a/packages/ai/test/auth-broker-refresher.test.ts +++ b/packages/ai/test/auth-broker-refresher.test.ts @@ -2,8 +2,8 @@ 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 { AuthBrokerRefresher, AuthStorage, SqliteAuthCredentialStore } from "../src"; -import * as oauthUtils from "../src/utils/oauth"; +import { AuthBrokerRefresher, AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai"; +import * as oauthUtils from "@oh-my-pi/pi-ai/registry/oauth"; const ANTHROPIC_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"] as const; const savedEnv: Partial> = {}; diff --git a/packages/ai/test/auth-broker-remote-store.test.ts b/packages/ai/test/auth-broker-remote-store.test.ts index 50be18938..cf553eed4 100644 --- a/packages/ai/test/auth-broker-remote-store.test.ts +++ b/packages/ai/test/auth-broker-remote-store.test.ts @@ -11,7 +11,7 @@ import { type SnapshotResponse, SqliteAuthCredentialStore, startAuthBroker, -} from "../src"; +} from "@oh-my-pi/pi-ai"; const ANTHROPIC_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"] as const; const savedEnv: Partial> = {}; diff --git a/packages/ai/test/auth-broker-snapshot-cache.test.ts b/packages/ai/test/auth-broker-snapshot-cache.test.ts index 9480472c6..51532b4d8 100644 --- a/packages/ai/test/auth-broker-snapshot-cache.test.ts +++ b/packages/ai/test/auth-broker-snapshot-cache.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { readAuthBrokerSnapshotCache, type SnapshotResponse, writeAuthBrokerSnapshotCache } from "../src"; +import { readAuthBrokerSnapshotCache, type SnapshotResponse, writeAuthBrokerSnapshotCache } from "@oh-my-pi/pi-ai"; const TOKEN = "broker-cache-token"; const URL = "http://127.0.0.1:8765"; diff --git a/packages/ai/test/auth-broker-wire.test.ts b/packages/ai/test/auth-broker-wire.test.ts index 469a7f489..60fb2f349 100644 --- a/packages/ai/test/auth-broker-wire.test.ts +++ b/packages/ai/test/auth-broker-wire.test.ts @@ -11,8 +11,8 @@ import { type SnapshotStreamEvent, SqliteAuthCredentialStore, startAuthBroker, -} from "../src"; -import * as oauthUtils from "../src/utils/oauth"; +} from "@oh-my-pi/pi-ai"; +import * as oauthUtils from "@oh-my-pi/pi-ai/registry/oauth"; const ANTHROPIC_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"] as const; const savedEnv: Partial> = {}; diff --git a/packages/ai/test/auth-gateway-anthropic-messages.test.ts b/packages/ai/test/auth-gateway-anthropic-messages.test.ts index 98679149f..94ca9e06c 100644 --- a/packages/ai/test/auth-gateway-anthropic-messages.test.ts +++ b/packages/ai/test/auth-gateway-anthropic-messages.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { encodeResponse, encodeStream, parseRequest } from "../src/providers/anthropic-messages-server"; -import type { AssistantMessage, AssistantMessageEvent, ToolResultMessage } from "../src/types"; -import { AssistantMessageEventStream } from "../src/utils/event-stream"; +import { encodeResponse, encodeStream, parseRequest } from "@oh-my-pi/pi-ai/providers/anthropic-messages-server"; +import type { AssistantMessage, AssistantMessageEvent, ToolResultMessage } from "@oh-my-pi/pi-ai/types"; +import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; function emptyUsage(): AssistantMessage["usage"] { return { diff --git a/packages/ai/test/auth-gateway-cache-key.test.ts b/packages/ai/test/auth-gateway-cache-key.test.ts index 4f6a55bca..9d98b28cc 100644 --- a/packages/ai/test/auth-gateway-cache-key.test.ts +++ b/packages/ai/test/auth-gateway-cache-key.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { resolvePromptCacheKey } from "../src/auth-gateway/http"; +import { resolvePromptCacheKey } from "@oh-my-pi/pi-ai/auth-gateway/http"; describe("resolvePromptCacheKey", () => { it("prefers body.prompt_cache_key over everything else", () => { diff --git a/packages/ai/test/auth-gateway-classify-error.test.ts b/packages/ai/test/auth-gateway-classify-error.test.ts index d780b266f..e55fdf602 100644 --- a/packages/ai/test/auth-gateway-classify-error.test.ts +++ b/packages/ai/test/auth-gateway-classify-error.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { classifyGatewayError } from "../src/auth-gateway/server"; +import { classifyGatewayError } from "@oh-my-pi/pi-ai/auth-gateway/server"; describe("auth-gateway classifyGatewayError", () => { it("honours an explicit numeric `status` property on the error", () => { diff --git a/packages/ai/test/auth-gateway-openai-chat.test.ts b/packages/ai/test/auth-gateway-openai-chat.test.ts index 4db5db89f..9ce5a92be 100644 --- a/packages/ai/test/auth-gateway-openai-chat.test.ts +++ b/packages/ai/test/auth-gateway-openai-chat.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { encodeResponse, encodeStream, parseRequest } from "../src/providers/openai-chat-server"; -import type { AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream } from "../src/types"; +import { encodeResponse, encodeStream, parseRequest } from "@oh-my-pi/pi-ai/providers/openai-chat-server"; +import type { AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream } from "@oh-my-pi/pi-ai/types"; function makeEventStream(events: AssistantMessageEvent[], final: AssistantMessage): AssistantMessageEventStream { async function* iter() { diff --git a/packages/ai/test/auth-gateway-openai-responses.test.ts b/packages/ai/test/auth-gateway-openai-responses.test.ts index 091928abf..d002caf86 100644 --- a/packages/ai/test/auth-gateway-openai-responses.test.ts +++ b/packages/ai/test/auth-gateway-openai-responses.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "bun:test"; -import { Effort } from "../src/effort"; -import { encodeResponse, encodeStream, parseRequest } from "../src/providers/openai-responses-server"; -import type { AssistantMessage } from "../src/types"; -import { AssistantMessageEventStream } from "../src/utils/event-stream"; +import { Effort } from "@oh-my-pi/pi-ai/effort"; +import { encodeResponse, encodeStream, parseRequest } from "@oh-my-pi/pi-ai/providers/openai-responses-server"; +import type { AssistantMessage } from "@oh-my-pi/pi-ai/types"; +import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; function zeroUsage(): AssistantMessage["usage"] { return { diff --git a/packages/ai/test/auth-gateway-pi-native.test.ts b/packages/ai/test/auth-gateway-pi-native.test.ts index 5f3a77a76..38e2d9ab9 100644 --- a/packages/ai/test/auth-gateway-pi-native.test.ts +++ b/packages/ai/test/auth-gateway-pi-native.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from "bun:test"; -import { Effort } from "../src/effort"; -import { encodeStream, formatError, parseRequest } from "../src/providers/pi-native-server"; +import { Effort } from "@oh-my-pi/pi-ai/effort"; +import { encodeStream, formatError, parseRequest } from "@oh-my-pi/pi-ai/providers/pi-native-server"; import type { AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream, Context, Usage, -} from "../src/types"; +} from "@oh-my-pi/pi-ai/types"; function makeEventStream(events: AssistantMessageEvent[], final: AssistantMessage): AssistantMessageEventStream { async function* iter() { diff --git a/packages/ai/test/auth-storage-api-key-login.test.ts b/packages/ai/test/auth-storage-api-key-login.test.ts index df2c6a244..7023aad92 100644 --- a/packages/ai/test/auth-storage-api-key-login.test.ts +++ b/packages/ai/test/auth-storage-api-key-login.test.ts @@ -4,10 +4,10 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; -import * as deepseekModule from "../src/utils/oauth/deepseek"; -import * as kagiModule from "../src/utils/oauth/kagi"; -import * as ollamaCloudModule from "../src/utils/oauth/ollama-cloud"; +import { AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; +import * as deepseekModule from "@oh-my-pi/pi-ai/registry/deepseek"; +import * as kagiModule from "@oh-my-pi/pi-ai/registry/kagi"; +import * as ollamaCloudModule from "@oh-my-pi/pi-ai/registry/ollama-cloud"; function countCredentialRows(dbPath: string, provider: string): number { const db = new Database(dbPath, { readonly: true }); diff --git a/packages/ai/test/auth-storage-broker-no-sentinel.test.ts b/packages/ai/test/auth-storage-broker-no-sentinel.test.ts index 5f6507eed..b26a1a2f4 100644 --- a/packages/ai/test/auth-storage-broker-no-sentinel.test.ts +++ b/packages/ai/test/auth-storage-broker-no-sentinel.test.ts @@ -7,8 +7,8 @@ import { AuthStorage, REMOTE_REFRESH_SENTINEL, SqliteAuthCredentialStore, -} from "../src/auth-storage"; -import * as oauthUtils from "../src/utils/oauth"; +} from "@oh-my-pi/pi-ai/auth-storage"; +import * as oauthUtils from "@oh-my-pi/pi-ai/registry/oauth"; describe("AuthStorage broker sentinel refresh", () => { let tempDir = ""; diff --git a/packages/ai/test/auth-storage-check-credentials.test.ts b/packages/ai/test/auth-storage-check-credentials.test.ts index e2f408669..3ad0ae274 100644 --- a/packages/ai/test/auth-storage-check-credentials.test.ts +++ b/packages/ai/test/auth-storage-check-credentials.test.ts @@ -29,8 +29,8 @@ import { type CompletionProbeInput, REMOTE_REFRESH_SENTINEL, type StoredAuthCredential, -} from "../src/auth-storage"; -import * as claudeUsage from "../src/usage/claude"; +} from "@oh-my-pi/pi-ai/auth-storage"; +import * as claudeUsage from "@oh-my-pi/pi-ai/usage/claude"; function oauthRow(id: number, email: string, opts?: { expired?: boolean }): StoredAuthCredential { const credential: AuthCredential = { diff --git a/packages/ai/test/auth-storage-codex-selection.test.ts b/packages/ai/test/auth-storage-codex-selection.test.ts index d31f775b4..e98fd6443 100644 --- a/packages/ai/test/auth-storage-codex-selection.test.ts +++ b/packages/ai/test/auth-storage-codex-selection.test.ts @@ -2,10 +2,10 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; -import type { UsageLimit, UsageProvider, UsageReport } from "../src/usage"; -import * as oauthUtils from "../src/utils/oauth"; -import type { OAuthCredentials } from "../src/utils/oauth/types"; +import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; +import * as oauthUtils from "@oh-my-pi/pi-ai/registry/oauth"; +import type { OAuthCredentials } from "@oh-my-pi/pi-ai/registry/oauth/types"; +import type { UsageLimit, UsageProvider, UsageReport } from "@oh-my-pi/pi-ai/usage"; const WEEK_MS = 7 * 24 * 60 * 60 * 1000; const HOUR_MS = 60 * 60 * 1000; diff --git a/packages/ai/test/auth-storage-config-override.test.ts b/packages/ai/test/auth-storage-config-override.test.ts index 16e140255..ad7f1a15d 100644 --- a/packages/ai/test/auth-storage-config-override.test.ts +++ b/packages/ai/test/auth-storage-config-override.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; +import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; import { withEnv } from "./helpers"; const SUPPRESS_ANTHROPIC_ENV = { diff --git a/packages/ai/test/auth-storage-credential-disabled-event.test.ts b/packages/ai/test/auth-storage-credential-disabled-event.test.ts index f9748a102..6574ad57e 100644 --- a/packages/ai/test/auth-storage-credential-disabled-event.test.ts +++ b/packages/ai/test/auth-storage-credential-disabled-event.test.ts @@ -5,8 +5,8 @@ import { AuthStorage, type CredentialDisabledEvent, type StoredAuthCredential, -} from "../src/auth-storage"; -import * as oauthUtils from "../src/utils/oauth"; +} from "@oh-my-pi/pi-ai/auth-storage"; +import * as oauthUtils from "@oh-my-pi/pi-ai/registry/oauth"; // Env vars short-circuit AuthStorage.getApiKey before the OAuth refresh path runs; suppress // them for every test in this file so the credential-disable code path can be exercised. diff --git a/packages/ai/test/auth-storage-credential-origin.test.ts b/packages/ai/test/auth-storage-credential-origin.test.ts index cbb50e26d..546e8e9d4 100644 --- a/packages/ai/test/auth-storage-credential-origin.test.ts +++ b/packages/ai/test/auth-storage-credential-origin.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; +import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; import { withEnv } from "./helpers"; // Clear every env var the providers under test alias, so ambient shell / ~/.env diff --git a/packages/ai/test/auth-storage-email-dedupe.test.ts b/packages/ai/test/auth-storage-email-dedupe.test.ts index e0ae13523..268a55912 100644 --- a/packages/ai/test/auth-storage-email-dedupe.test.ts +++ b/packages/ai/test/auth-storage-email-dedupe.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { AuthStorage, type OAuthCredential, SqliteAuthCredentialStore } from "../src/auth-storage"; +import { AuthStorage, type OAuthCredential, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; const LEGACY_TIMESTAMP = 1_700_000_000; diff --git a/packages/ai/test/auth-storage-force-refresh-rotate.test.ts b/packages/ai/test/auth-storage-force-refresh-rotate.test.ts index fc7d0bb01..1ae31a67b 100644 --- a/packages/ai/test/auth-storage-force-refresh-rotate.test.ts +++ b/packages/ai/test/auth-storage-force-refresh-rotate.test.ts @@ -2,8 +2,8 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; -import { registerOAuthProvider, unregisterOAuthProviders } from "../src/utils/oauth"; +import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; +import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/registry/oauth"; const PROVIDER = "unit-rotate-oauth"; const SOURCE = "auth-storage-force-refresh-rotate-test"; diff --git a/packages/ai/test/auth-storage-oauth-refresh-race.test.ts b/packages/ai/test/auth-storage-oauth-refresh-race.test.ts index 25797974f..637d35bbe 100644 --- a/packages/ai/test/auth-storage-oauth-refresh-race.test.ts +++ b/packages/ai/test/auth-storage-oauth-refresh-race.test.ts @@ -7,8 +7,8 @@ import { AuthStorage, type CredentialDisabledEvent, SqliteAuthCredentialStore, -} from "../src/auth-storage"; -import * as oauthUtils from "../src/utils/oauth"; +} from "@oh-my-pi/pi-ai/auth-storage"; +import * as oauthUtils from "@oh-my-pi/pi-ai/registry/oauth"; import { withEnv } from "./helpers"; const SUPPRESS_ANTHROPIC_ENV = { diff --git a/packages/ai/test/auth-storage-refresh-skew.test.ts b/packages/ai/test/auth-storage-refresh-skew.test.ts index d3ed0517a..9e5e37e66 100644 --- a/packages/ai/test/auth-storage-refresh-skew.test.ts +++ b/packages/ai/test/auth-storage-refresh-skew.test.ts @@ -2,8 +2,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; -import { registerOAuthProvider, unregisterOAuthProviders } from "../src/utils/oauth"; +import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; +import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/registry/oauth"; describe("AuthStorage OAuth refresh skew", () => { let tempDir = ""; diff --git a/packages/ai/test/auth-storage-usage-cache.test.ts b/packages/ai/test/auth-storage-usage-cache.test.ts index fcb2d87a6..219d49f33 100644 --- a/packages/ai/test/auth-storage-usage-cache.test.ts +++ b/packages/ai/test/auth-storage-usage-cache.test.ts @@ -16,9 +16,9 @@ import { type AuthCredentialStore, AuthStorage, type StoredAuthCredential, -} from "../src/auth-storage"; -import type { UsageLimit, UsageReport } from "../src/usage"; -import * as claudeUsage from "../src/usage/claude"; +} from "@oh-my-pi/pi-ai/auth-storage"; +import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai/usage"; +import * as claudeUsage from "@oh-my-pi/pi-ai/usage/claude"; function anthropicReports(reports: UsageReport[] | null): UsageReport[] { return (reports ?? []).filter(r => r.provider === "anthropic"); diff --git a/packages/ai/test/aws-credentials.test.ts b/packages/ai/test/aws-credentials.test.ts index 851c4bc58..5fd483868 100644 --- a/packages/ai/test/aws-credentials.test.ts +++ b/packages/ai/test/aws-credentials.test.ts @@ -6,7 +6,7 @@ import { clearAwsCredentialCache, resolveAwsCredentials, tokenizeCredentialProcessCommand, -} from "../src/providers/aws-credentials"; +} from "@oh-my-pi/pi-ai/providers/aws-credentials"; // `credential_process` integration coverage. Drives a real `Bun.spawn` // against a fixture script so the JSON envelope contract, exit-code diff --git a/packages/ai/test/aws-eventstream.test.ts b/packages/ai/test/aws-eventstream.test.ts index 347660ca1..b75cb1cb2 100644 --- a/packages/ai/test/aws-eventstream.test.ts +++ b/packages/ai/test/aws-eventstream.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { crc32, decodeEventStream, decodeMessage } from "../src/providers/aws-eventstream"; +import { crc32, decodeEventStream, decodeMessage } from "@oh-my-pi/pi-ai/providers/aws-eventstream"; // ---- Frame builder (mirrors @smithy/eventstream-codec but in-process so the // test owns the bytes). The decoder is the production code; we encode here for diff --git a/packages/ai/test/aws-sigv4.test.ts b/packages/ai/test/aws-sigv4.test.ts index bcb661f19..2b4d70d1d 100644 --- a/packages/ai/test/aws-sigv4.test.ts +++ b/packages/ai/test/aws-sigv4.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { formatAmzDate, getSigningKey, signRequest, toHex } from "../src/providers/aws-sigv4"; +import { formatAmzDate, getSigningKey, signRequest, toHex } from "@oh-my-pi/pi-ai/providers/aws-sigv4"; // Canonical AWS SigV4 test vectors. Sourced from the // `aws-sig-v4-test-suite` published with the SigV4 spec. diff --git a/packages/ai/test/azure-openai-responses-stream.test.ts b/packages/ai/test/azure-openai-responses-stream.test.ts index 416cbb044..ca44715f9 100644 --- a/packages/ai/test/azure-openai-responses-stream.test.ts +++ b/packages/ai/test/azure-openai-responses-stream.test.ts @@ -1,6 +1,9 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { type AzureOpenAIResponsesOptions, streamAzureOpenAIResponses } from "../src/providers/azure-openai-responses"; -import type { Context, Model, Tool } from "../src/types"; +import { + type AzureOpenAIResponsesOptions, + streamAzureOpenAIResponses, +} from "@oh-my-pi/pi-ai/providers/azure-openai-responses"; +import type { Context, Model, Tool } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/callback-server-manual-input.test.ts b/packages/ai/test/callback-server-manual-input.test.ts index 4583bf228..04e76fee9 100644 --- a/packages/ai/test/callback-server-manual-input.test.ts +++ b/packages/ai/test/callback-server-manual-input.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { OAuthCallbackFlow } from "../src/utils/oauth/callback-server"; -import type { OAuthCredentials } from "../src/utils/oauth/types"; +import { OAuthCallbackFlow } from "@oh-my-pi/pi-ai/registry/oauth/callback-server"; +import type { OAuthCredentials } from "@oh-my-pi/pi-ai/registry/oauth/types"; class TestCallbackFlow extends OAuthCallbackFlow { async generateAuthUrl(_state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> { diff --git a/packages/ai/test/claude-ratelimit-headers.test.ts b/packages/ai/test/claude-ratelimit-headers.test.ts index e9d2188dc..4b31abb53 100644 --- a/packages/ai/test/claude-ratelimit-headers.test.ts +++ b/packages/ai/test/claude-ratelimit-headers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import type { UsageLimit, UsageReport } from "../src/usage"; -import { parseClaudeRateLimitHeaders } from "../src/usage/claude"; +import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai/usage"; +import { parseClaudeRateLimitHeaders } from "@oh-my-pi/pi-ai/usage/claude"; const NOW = 1_780_400_000_000; diff --git a/packages/ai/test/claude-usage-headers.test.ts b/packages/ai/test/claude-usage-headers.test.ts index a20dc6a1f..3aab042ad 100644 --- a/packages/ai/test/claude-usage-headers.test.ts +++ b/packages/ai/test/claude-usage-headers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import type { UsageFetchContext } from "../src/usage"; -import { claudeUsageProvider } from "../src/usage/claude"; +import type { UsageFetchContext } from "@oh-my-pi/pi-ai/usage"; +import { claudeUsageProvider } from "@oh-my-pi/pi-ai/usage/claude"; function getHeaderCaseInsensitive( headers: Headers | Record> | string[][] | undefined, diff --git a/packages/ai/test/claude-usage-retry.test.ts b/packages/ai/test/claude-usage-retry.test.ts index 91c11141f..52377a412 100644 --- a/packages/ai/test/claude-usage-retry.test.ts +++ b/packages/ai/test/claude-usage-retry.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import type { UsageFetchContext } from "../src/usage"; -import { claudeUsageProvider } from "../src/usage/claude"; +import type { UsageFetchContext } from "@oh-my-pi/pi-ai/usage"; +import { claudeUsageProvider } from "@oh-my-pi/pi-ai/usage/claude"; const VALID_PAYLOAD = { five_hour: { utilization: 42, resets_at: new Date(Date.now() + 5 * 60_000).toISOString() }, diff --git a/packages/ai/test/cursor-exec-handlers.test.ts b/packages/ai/test/cursor-exec-handlers.test.ts index b0ec0af0b..a11c9563c 100644 --- a/packages/ai/test/cursor-exec-handlers.test.ts +++ b/packages/ai/test/cursor-exec-handlers.test.ts @@ -4,9 +4,9 @@ import { buildCursorSystemPromptJsons, resolveExecHandler, streamCursor, -} from "../src/providers/cursor"; -import type { AgentRunRequest } from "../src/providers/cursor/gen/agent_pb"; -import type { Context, Model } from "../src/types"; +} from "@oh-my-pi/pi-ai/providers/cursor"; +import type { AgentRunRequest } from "@oh-my-pi/pi-ai/providers/cursor/gen/agent_pb"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const cursorModel: Model<"cursor-agent"> = { id: "cursor-composer-2.5", diff --git a/packages/ai/test/deepseek-reasoning-content.test.ts b/packages/ai/test/deepseek-reasoning-content.test.ts index 180885e4d..b8aebf8cf 100644 --- a/packages/ai/test/deepseek-reasoning-content.test.ts +++ b/packages/ai/test/deepseek-reasoning-content.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { convertMessages, detectCompat } from "../src/providers/openai-completions"; -import type { AssistantMessage, Model, ThinkingContent, ToolCall } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { convertMessages, detectCompat } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { AssistantMessage, Model, ThinkingContent, ToolCall } from "@oh-my-pi/pi-ai/types"; function deepseekModel(overrides: Partial>): Model<"openai-completions"> { return { diff --git a/packages/ai/test/event-stream.test.ts b/packages/ai/test/event-stream.test.ts index ff701ff44..0d9c97a8b 100644 --- a/packages/ai/test/event-stream.test.ts +++ b/packages/ai/test/event-stream.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import type { AssistantMessage } from "../src/types"; -import { AssistantMessageEventStream } from "../src/utils/event-stream"; +import type { AssistantMessage } from "@oh-my-pi/pi-ai/types"; +import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; function createPartial(text = ""): AssistantMessage { return { diff --git a/packages/ai/test/firepass.live.ts b/packages/ai/test/firepass.live.ts index 058492ba1..2a678ef70 100644 --- a/packages/ai/test/firepass.live.ts +++ b/packages/ai/test/firepass.live.ts @@ -8,9 +8,9 @@ * 2. The PR #1199 P2 fix (xhigh → max) actually clears the wire — without * the mapping Fireworks 400s the request. */ -import { getBundledModel } from "../src/models"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import type { Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const apiKey = process.env.FIREPASS_API_KEY; if (!apiKey) { diff --git a/packages/ai/test/firepass.test.ts b/packages/ai/test/firepass.test.ts index fdfa5a2c7..b6b8a9e72 100644 --- a/packages/ai/test/firepass.test.ts +++ b/packages/ai/test/firepass.test.ts @@ -7,9 +7,9 @@ * form at request time. */ import { afterEach, describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import type { Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/github-copilot-anthropic-auth.test.ts b/packages/ai/test/github-copilot-anthropic-auth.test.ts index ab2ce1c82..b1918e458 100644 --- a/packages/ai/test/github-copilot-anthropic-auth.test.ts +++ b/packages/ai/test/github-copilot-anthropic-auth.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { buildAnthropicClientOptions, streamAnthropic } from "../src/providers/anthropic"; -import type { Context, Model } from "../src/types"; -import { buildAnthropicUrl } from "../src/utils/anthropic-auth"; -import { OPENCODE_HEADERS } from "../src/utils/oauth/github-copilot"; +import { buildAnthropicClientOptions, streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic"; +import { OPENCODE_HEADERS } from "@oh-my-pi/pi-ai/registry/oauth/github-copilot"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; +import { buildAnthropicUrl } from "@oh-my-pi/pi-ai/utils/anthropic-auth"; const originalFetch = global.fetch; diff --git a/packages/ai/test/github-copilot-error.test.ts b/packages/ai/test/github-copilot-error.test.ts index fe451f7cb..e7fce6edb 100644 --- a/packages/ai/test/github-copilot-error.test.ts +++ b/packages/ai/test/github-copilot-error.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { rewriteCopilotError } from "../src/utils/http-inspector"; +import { rewriteCopilotError } from "@oh-my-pi/pi-ai/utils/http-inspector"; function errorWithStatus(status: number): Error { const err = new Error(`${status} Unauthorized`); diff --git a/packages/ai/test/github-copilot-headers.test.ts b/packages/ai/test/github-copilot-headers.test.ts index d0257e3b1..f293a50f6 100644 --- a/packages/ai/test/github-copilot-headers.test.ts +++ b/packages/ai/test/github-copilot-headers.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { buildCopilotDynamicHeaders, getCopilotInitiatorOverride, getCopilotPremiumMultiplier, hasCopilotVisionInput, inferCopilotInitiator, -} from "../src/providers/github-copilot-headers"; -import type { Message } from "../src/types"; +} from "@oh-my-pi/pi-ai/providers/github-copilot-headers"; +import type { Message } from "@oh-my-pi/pi-ai/types"; describe("inferCopilotInitiator", () => { it("returns 'user' when there are no messages", () => { diff --git a/packages/ai/test/github-copilot-login.test.ts b/packages/ai/test/github-copilot-login.test.ts index b4ff72e75..5f2ca2faa 100644 --- a/packages/ai/test/github-copilot-login.test.ts +++ b/packages/ai/test/github-copilot-login.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { loginGitHubCopilot } from "../src/utils/oauth/github-copilot"; +import { loginGitHubCopilot } from "@oh-my-pi/pi-ai/registry/oauth/github-copilot"; const originalFetch = global.fetch; const FAST_POLL_OPTIONS = { pollIntervalFloorMs: 0, pollIntervalScaleMs: 1 } as const; diff --git a/packages/ai/test/github-copilot-model-limits.test.ts b/packages/ai/test/github-copilot-model-limits.test.ts index 07d76ee87..20e6248e5 100644 --- a/packages/ai/test/github-copilot-model-limits.test.ts +++ b/packages/ai/test/github-copilot-model-limits.test.ts @@ -2,10 +2,10 @@ 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 { Effort } from "../src/effort"; -import { createModelManager } from "../src/model-manager"; -import { getBundledModel } from "../src/models"; -import { githubCopilotModelManagerOptions } from "../src/provider-models/openai-compat"; +import { Effort } from "@oh-my-pi/pi-ai/effort"; +import { createModelManager } from "@oh-my-pi/pi-ai/model-manager"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { githubCopilotModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; const originalFetch = global.fetch; diff --git a/packages/ai/test/github-copilot-oauth.test.ts b/packages/ai/test/github-copilot-oauth.test.ts index 43a0b6d16..36a44ee84 100644 --- a/packages/ai/test/github-copilot-oauth.test.ts +++ b/packages/ai/test/github-copilot-oauth.test.ts @@ -3,7 +3,7 @@ import { getGitHubCopilotBaseUrl, normalizeGitHubCopilotEnterpriseDomain, parseGitHubCopilotApiKey, -} from "../src/utils/oauth/github-copilot"; +} from "@oh-my-pi/pi-ai/registry/oauth/github-copilot"; describe("GitHub Copilot OAuth helpers", () => { it("treats github.com as the public Copilot host", () => { diff --git a/packages/ai/test/github-copilot-openai-base-url.test.ts b/packages/ai/test/github-copilot-openai-base-url.test.ts index 65d9f6520..930e33188 100644 --- a/packages/ai/test/github-copilot-openai-base-url.test.ts +++ b/packages/ai/test/github-copilot-openai-base-url.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import { streamOpenAIResponses } from "../src/providers/openai-responses"; -import type { Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/github-copilot-reasoning.test.ts b/packages/ai/test/github-copilot-reasoning.test.ts index 28746c85d..c8b5adac8 100644 --- a/packages/ai/test/github-copilot-reasoning.test.ts +++ b/packages/ai/test/github-copilot-reasoning.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { Effort } from "../src/effort"; -import { getBundledModel } from "../src/models"; -import { streamAnthropic } from "../src/providers/anthropic"; -import { streamOpenAIResponses } from "../src/providers/openai-responses"; -import type { Context, Model } from "../src/types"; +import { Effort } from "@oh-my-pi/pi-ai/effort"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic"; +import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const testContext: Context = { messages: [{ role: "user", content: "hello", timestamp: Date.now() }], diff --git a/packages/ai/test/google-antigravity-usage.test.ts b/packages/ai/test/google-antigravity-usage.test.ts index 4fdec1ae0..fac79ba27 100644 --- a/packages/ai/test/google-antigravity-usage.test.ts +++ b/packages/ai/test/google-antigravity-usage.test.ts @@ -5,8 +5,8 @@ * different model entries, and handles mixed-case tier names. */ import { describe, expect, it } from "bun:test"; -import type { UsageFetchContext, UsageFetchParams } from "../src/usage"; -import { antigravityUsageProvider } from "../src/usage/google-antigravity"; +import type { UsageFetchContext, UsageFetchParams } from "@oh-my-pi/pi-ai/usage"; +import { antigravityUsageProvider } from "@oh-my-pi/pi-ai/usage/google-antigravity"; const accessTokenFixture = (() => { const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); diff --git a/packages/ai/test/google-gemini-cli-3x-thinking.test.ts b/packages/ai/test/google-gemini-cli-3x-thinking.test.ts index 9e1caf756..117e3aba5 100644 --- a/packages/ai/test/google-gemini-cli-3x-thinking.test.ts +++ b/packages/ai/test/google-gemini-cli-3x-thinking.test.ts @@ -1,9 +1,9 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import { Effort } from "@oh-my-pi/pi-ai"; import { enrichModelThinking } from "@oh-my-pi/pi-ai/model-thinking"; +import { streamSimple } from "@oh-my-pi/pi-ai/stream"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; import { hookFetch } from "@oh-my-pi/pi-utils"; -import { streamSimple } from "../src/stream"; -import type { Context, Model } from "../src/types"; interface GeminiCliThinkingConfig { thinkingLevel?: string; diff --git a/packages/ai/test/google-gemini-cli-alignment.test.ts b/packages/ai/test/google-gemini-cli-alignment.test.ts index bfadc6171..a2367454c 100644 --- a/packages/ai/test/google-gemini-cli-alignment.test.ts +++ b/packages/ai/test/google-gemini-cli-alignment.test.ts @@ -1,15 +1,15 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { hookFetch } from "@oh-my-pi/pi-utils"; -import * as geminiCliProvider from "../src/providers/google-gemini-cli"; +import * as geminiCliProvider from "@oh-my-pi/pi-ai/providers/google-gemini-cli"; import { ANTIGRAVITY_SYSTEM_INSTRUCTION, buildRequest, parseGeminiCliCredentials, shouldRefreshGeminiCliCredentials, streamGoogleGeminiCli, -} from "../src/providers/google-gemini-cli"; -import type { Context, Model, TJsonSchema } from "../src/types"; -import { getOAuthApiKey } from "../src/utils/oauth"; +} from "@oh-my-pi/pi-ai/providers/google-gemini-cli"; +import { getOAuthApiKey } from "@oh-my-pi/pi-ai/registry/oauth"; +import type { Context, Model, TJsonSchema } from "@oh-my-pi/pi-ai/types"; +import { hookFetch } from "@oh-my-pi/pi-utils"; function createModel(provider: "google-gemini-cli" | "google-antigravity"): Model<"google-gemini-cli"> { return { diff --git a/packages/ai/test/google-tool-choice.test.ts b/packages/ai/test/google-tool-choice.test.ts index f8fd5e09a..aeac29875 100644 --- a/packages/ai/test/google-tool-choice.test.ts +++ b/packages/ai/test/google-tool-choice.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "bun:test"; import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { buildGoogleGenerateContentParams } from "@oh-my-pi/pi-ai/providers/google-shared"; +import { mapGoogleToolChoice } from "@oh-my-pi/pi-ai/stream"; import type { Context, Tool, ToolChoice } from "@oh-my-pi/pi-ai/types"; -import { buildGoogleGenerateContentParams } from "../src/providers/google-shared"; -import { mapGoogleToolChoice } from "../src/stream"; describe("mapGoogleToolChoice (F7)", () => { it("returns string passthrough for auto/none/any", () => { diff --git a/packages/ai/test/google-vertex-discovery.test.ts b/packages/ai/test/google-vertex-discovery.test.ts index 7a4471f87..f7c2780b2 100644 --- a/packages/ai/test/google-vertex-discovery.test.ts +++ b/packages/ai/test/google-vertex-discovery.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { resolveProviderModels } from "../src/model-manager"; -import { googleVertexModelManagerOptions } from "../src/provider-models/google"; -import { MODELS_DEV_PROVIDER_DESCRIPTORS, mapModelsDevToModels } from "../src/provider-models/openai-compat"; +import { resolveProviderModels } from "@oh-my-pi/pi-ai/model-manager"; +import { googleVertexModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/google"; +import { MODELS_DEV_PROVIDER_DESCRIPTORS, mapModelsDevToModels } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; const googleVertexModelsDevPayload = { "google-vertex": { diff --git a/packages/ai/test/issue-1203-repro.test.ts b/packages/ai/test/issue-1203-repro.test.ts index 48a856013..0a6b3fc52 100644 --- a/packages/ai/test/issue-1203-repro.test.ts +++ b/packages/ai/test/issue-1203-repro.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import type { Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/issue-1270-repro.test.ts b/packages/ai/test/issue-1270-repro.test.ts index 00d3914e1..1169b6b0c 100644 --- a/packages/ai/test/issue-1270-repro.test.ts +++ b/packages/ai/test/issue-1270-repro.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { __resetVertexTokenCache } from "../src/providers/google-auth"; -import { streamGoogleVertex } from "../src/providers/google-vertex"; -import type { Model } from "../src/types"; +import { __resetVertexTokenCache } from "@oh-my-pi/pi-ai/providers/google-auth"; +import { streamGoogleVertex } from "@oh-my-pi/pi-ai/providers/google-vertex"; +import type { Model } from "@oh-my-pi/pi-ai/types"; const OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"; const METADATA_TOKEN_URL = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token"; diff --git a/packages/ai/test/issue-1373-repro.test.ts b/packages/ai/test/issue-1373-repro.test.ts index 88e855f4e..ce0e65111 100644 --- a/packages/ai/test/issue-1373-repro.test.ts +++ b/packages/ai/test/issue-1373-repro.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { Effort } from "../src/effort"; -import { streamBedrock } from "../src/providers/amazon-bedrock"; -import type { Context, Model } from "../src/types"; +import { Effort } from "@oh-my-pi/pi-ai/effort"; +import { streamBedrock } from "@oh-my-pi/pi-ai/providers/amazon-bedrock"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalSkipAuth = process.env.AWS_BEDROCK_SKIP_AUTH; diff --git a/packages/ai/test/issue-1399-repro.test.ts b/packages/ai/test/issue-1399-repro.test.ts index f03e74a90..fc523c553 100644 --- a/packages/ai/test/issue-1399-repro.test.ts +++ b/packages/ai/test/issue-1399-repro.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { streamBedrock } from "@oh-my-pi/pi-ai/providers/amazon-bedrock"; +import { clearAwsCredentialCache } from "@oh-my-pi/pi-ai/providers/aws-credentials"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; import { hookFetch } from "@oh-my-pi/pi-utils"; -import { streamBedrock } from "../src/providers/amazon-bedrock"; -import { clearAwsCredentialCache } from "../src/providers/aws-credentials"; -import type { Context, Model } from "../src/types"; const model: Model<"bedrock-converse-stream"> = { id: "zai.glm-5", diff --git a/packages/ai/test/issue-1417-repro.test.ts b/packages/ai/test/issue-1417-repro.test.ts index 4ffa72f4c..ed1ffea12 100644 --- a/packages/ai/test/issue-1417-repro.test.ts +++ b/packages/ai/test/issue-1417-repro.test.ts @@ -2,9 +2,9 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { readModelCache } from "../src/model-cache"; -import { resolveProviderModels } from "../src/model-manager"; -import type { Model } from "../src/types"; +import { readModelCache } from "@oh-my-pi/pi-ai/model-cache"; +import { resolveProviderModels } from "@oh-my-pi/pi-ai/model-manager"; +import type { Model } from "@oh-my-pi/pi-ai/types"; const TTL_MS = 24 * 60 * 60 * 1000; diff --git a/packages/ai/test/issue-1617-repro.test.ts b/packages/ai/test/issue-1617-repro.test.ts index 84e546340..6caf43635 100644 --- a/packages/ai/test/issue-1617-repro.test.ts +++ b/packages/ai/test/issue-1617-repro.test.ts @@ -19,7 +19,7 @@ import { type ModelsDevModel, opencodeGoModelManagerOptions, opencodeZenModelManagerOptions, -} from "../src/provider-models/openai-compat"; +} from "@oh-my-pi/pi-ai/provider-models/openai-compat"; const OPENCODE_ZEN_BASE = "https://opencode.ai/zen/v1"; const OPENCODE_GO_BASE = "https://opencode.ai/zen/go/v1"; diff --git a/packages/ai/test/issue-1776-repro.test.ts b/packages/ai/test/issue-1776-repro.test.ts index 0a32dcdd8..f929221ff 100644 --- a/packages/ai/test/issue-1776-repro.test.ts +++ b/packages/ai/test/issue-1776-repro.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import type { Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/issue-1838-repro.test.ts b/packages/ai/test/issue-1838-repro.test.ts index a7df4c00a..12e51a374 100644 --- a/packages/ai/test/issue-1838-repro.test.ts +++ b/packages/ai/test/issue-1838-repro.test.ts @@ -33,9 +33,9 @@ * own native format and would reject the extra key. */ import { afterEach, describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import type { AssistantMessage, Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { AssistantMessage, Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/issue-1846-repro.test.ts b/packages/ai/test/issue-1846-repro.test.ts index 66ba01a03..39df0d735 100644 --- a/packages/ai/test/issue-1846-repro.test.ts +++ b/packages/ai/test/issue-1846-repro.test.ts @@ -1,11 +1,11 @@ import { Database } from "bun:sqlite"; import { afterEach, describe, expect, it, vi } from "bun:test"; -import { AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; -import { xiaomiModelManagerOptions } from "../src/provider-models/openai-compat"; -import { convertMessages, detectCompat } from "../src/providers/openai-completions"; -import type { AssistantMessage, Model, ThinkingContent, ToolCall } from "../src/types"; -import { getOAuthProviders } from "../src/utils/oauth"; +import { AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; +import { xiaomiModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; +import { convertMessages, detectCompat } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import { getOAuthProviders } from "@oh-my-pi/pi-ai/registry/oauth"; +import type { AssistantMessage, Model, ThinkingContent, ToolCall } from "@oh-my-pi/pi-ai/types"; const TP_KEY = "tp-ci1p8t1w4e1sbxgyc8v65tnrjbzro287igmvyf25van9mt76"; const SGP_BASE_URL = "https://token-plan-sgp.xiaomimimo.com/v1"; diff --git a/packages/ai/test/issue-1849-repro.test.ts b/packages/ai/test/issue-1849-repro.test.ts index 105e4539e..63a912fd7 100644 --- a/packages/ai/test/issue-1849-repro.test.ts +++ b/packages/ai/test/issue-1849-repro.test.ts @@ -13,12 +13,12 @@ * generator regenerates. */ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { clampFireworksKimiMaxTokens, FIREWORKS_KIMI_MAX_TOKENS, isFireworksKimiK2ModelId, -} from "../src/provider-models/openai-compat"; +} from "@oh-my-pi/pi-ai/provider-models/openai-compat"; describe("Fireworks Kimi K2 maxTokens cap (#1849)", () => { it("recognizes Kimi K2.x public and wire ids", () => { diff --git a/packages/ai/test/issue-2080-repro.test.ts b/packages/ai/test/issue-2080-repro.test.ts index e3d6c3dbe..36f9c657f 100644 --- a/packages/ai/test/issue-2080-repro.test.ts +++ b/packages/ai/test/issue-2080-repro.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import type { Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/issue-2113-repro.test.ts b/packages/ai/test/issue-2113-repro.test.ts new file mode 100644 index 000000000..fa3267cd7 --- /dev/null +++ b/packages/ai/test/issue-2113-repro.test.ts @@ -0,0 +1,180 @@ +/** + * Issue #2113 — `kimik2 does not work on the latest omp` + * + * Reporter: with only `MOONSHOT_API_KEY` set, selecting `kimi-k2.6` and + * sending any text leaves the agent stuck on "Working..." with no output. + * + * Root cause: the `moonshot` provider only bundled `kimi-k2.5`, and the + * `moonshotModelManagerOptions` discovery mapper only promoted ids + * containing `"thinking"` to `reasoning: true`. `kimi-k2.6` fell through + * with `reasoning: false`, so the openai-completions `buildParams` z.ai + * branch was skipped entirely and Moonshot received a request with no + * `thinking` parameter — Moonshot K2.6 stalls under that shape (the same + * native-API quirk documented in the #1838 fix that introduced + * `thinking.keep` for K2.6). + * + * The fix marks every `kimi-k2.x` id as reasoning + vision in the + * moonshot discovery mapper and stamps default thinking metadata. + */ +import { afterEach, describe, expect, it } from "bun:test"; +import { Effort } from "@oh-my-pi/pi-ai/effort"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { moonshotModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { AssistantMessage, Context, Model } from "@oh-my-pi/pi-ai/types"; + +const originalFetch = global.fetch; + +afterEach(() => { + global.fetch = originalFetch; +}); + +function moonshotKimiModel(id: string, reasoning: boolean): Model<"openai-completions"> { + return { + ...getBundledModel("openai", "gpt-4o-mini"), + api: "openai-completions", + provider: "moonshot", + baseUrl: "https://api.moonshot.ai/v1", + id, + reasoning, + }; +} + +function basicContext(): Context { + return { + messages: [{ role: "user", content: "hi", timestamp: Date.now() }], + }; +} + +function encodeSseChunks(chunks: ReadonlyArray>): string { + const lines = chunks.map(c => `data: ${JSON.stringify(c)}\n\n`); + lines.push("data: [DONE]\n\n"); + return lines.join(""); +} + +function buildMockMoonshotResponse(): Response { + const body = encodeSseChunks([ + { + id: "chatcmpl-k26-1", + object: "chat.completion.chunk", + created: 1, + model: "kimi-k2.6", + choices: [{ index: 0, delta: { role: "assistant", content: "Hello!" }, finish_reason: null }], + }, + { + id: "chatcmpl-k26-1", + object: "chat.completion.chunk", + created: 1, + model: "kimi-k2.6", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }, + ]); + return new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +interface CapturedRequest { + url: string; + body: Record; +} + +async function runHiTurn( + model: Model<"openai-completions">, +): Promise<{ captured: CapturedRequest; assistant: AssistantMessage }> { + const captured: CapturedRequest = { url: "", body: {} }; + const fetchImpl = async (input: string | URL | Request, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + captured.url = url; + const raw = typeof init?.body === "string" ? init.body : ""; + captured.body = raw ? (JSON.parse(raw) as Record) : {}; + return buildMockMoonshotResponse(); + }; + global.fetch = Object.assign(fetchImpl, { preconnect: originalFetch.preconnect }); + + const stream = streamOpenAICompletions(model, basicContext(), { apiKey: "test-key" }); + for await (const _ of stream) { + // drain until terminal event + } + const assistant = await stream.result(); + return { captured, assistant }; +} + +describe("issue #2113 — moonshot kimi-k2.6 discovery and wire format", () => { + it("moonshot discovery mapper marks kimi-k2.6 as reasoning + vision with thinking metadata", async () => { + const opts = moonshotModelManagerOptions({ apiKey: "test-key" }); + const fetchImpl = async (input: string | URL | Request): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + expect(url).toContain("api.moonshot.ai/v1/models"); + const body = { + object: "list", + data: [ + { id: "kimi-k2.5", object: "model", owned_by: "moonshot" }, + { id: "kimi-k2.6", object: "model", owned_by: "moonshot" }, + { id: "kimi-k2-thinking", object: "model", owned_by: "moonshot" }, + ], + }; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + global.fetch = Object.assign(fetchImpl, { preconnect: originalFetch.preconnect }); + + const models = await opts.fetchDynamicModels?.(); + expect(models).toBeDefined(); + const byId = new Map(models?.map(m => [m.id, m])); + + const k25 = byId.get("kimi-k2.5"); + expect(k25?.reasoning).toBe(true); + expect(k25?.input).toEqual(["text", "image"]); + expect(k25?.thinking).toBeDefined(); + + const k26 = byId.get("kimi-k2.6"); + expect(k26?.reasoning).toBe(true); + expect(k26?.input).toEqual(["text", "image"]); + expect(k26?.thinking).toEqual({ mode: "effort", minLevel: Effort.Minimal, maxLevel: Effort.High }); + + const thinkingOnly = byId.get("kimi-k2-thinking"); + expect(thinkingOnly?.reasoning).toBe(true); + expect(thinkingOnly?.thinking).toBeDefined(); + }); + + it("wire body for moonshot kimi-k2.6 carries an explicit thinking parameter", async () => { + // The discovery mapper now stamps reasoning=true on Moonshot K2.6, so the + // openai-completions z.ai branch fires and emits `thinking: {type}`. Without + // this, Moonshot K2.6 stalls on first turn (the original #2113 symptom). + const model = moonshotKimiModel("kimi-k2.6", true); + const { captured, assistant } = await runHiTurn(model); + + expect(captured.url).toContain("api.moonshot.ai/v1/chat/completions"); + expect(captured.body.thinking).toEqual({ type: "disabled" }); + expect(assistant.errorMessage).toBeUndefined(); + const textBlock = assistant.content.find(b => b.type === "text"); + expect(textBlock).toBeDefined(); + }); + + it("wire body includes thinking.keep='all' when reasoning is explicitly requested", async () => { + const model = moonshotKimiModel("kimi-k2.6", true); + const captured: CapturedRequest = { url: "", body: {} }; + const fetchImpl = async (input: string | URL | Request, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + captured.url = url; + const raw = typeof init?.body === "string" ? init.body : ""; + captured.body = raw ? (JSON.parse(raw) as Record) : {}; + return buildMockMoonshotResponse(); + }; + global.fetch = Object.assign(fetchImpl, { preconnect: originalFetch.preconnect }); + + const stream = streamOpenAICompletions(model, basicContext(), { + apiKey: "test-key", + reasoning: "high", + }); + for await (const _ of stream) { + // drain + } + + expect(captured.body.thinking).toEqual({ type: "enabled", keep: "all" }); + }); +}); diff --git a/packages/ai/test/issue-772-repro.test.ts b/packages/ai/test/issue-772-repro.test.ts index 1d3bb7758..76594649d 100644 --- a/packages/ai/test/issue-772-repro.test.ts +++ b/packages/ai/test/issue-772-repro.test.ts @@ -1,9 +1,8 @@ import { describe, expect, it } from "bun:test"; +import { xiaomiModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; +import { loginXiaomi } from "@oh-my-pi/pi-ai/registry/oauth/xiaomi"; import { hookFetch } from "@oh-my-pi/pi-utils"; -import { xiaomiModelManagerOptions } from "../src/provider-models/openai-compat"; -import { loginXiaomi } from "../src/utils/oauth/xiaomi"; - const TOKEN_PLAN_SGP_HOST = "token-plan-sgp.xiaomimimo.com"; const STANDARD_HOST = "api.xiaomimimo.com"; diff --git a/packages/ai/test/issue-826-repro.test.ts b/packages/ai/test/issue-826-repro.test.ts index dd78aeda0..7cb629cdf 100644 --- a/packages/ai/test/issue-826-repro.test.ts +++ b/packages/ai/test/issue-826-repro.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { Effort } from "../src/effort"; -import { streamAnthropic } from "../src/providers/anthropic"; -import type { Context, Model, Tool } from "../src/types"; +import { Effort } from "@oh-my-pi/pi-ai/effort"; +import { streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic"; +import type { Context, Model, Tool } from "@oh-my-pi/pi-ai/types"; const baseModel: Model<"anthropic-messages"> = { id: "claude-sonnet-4-5", diff --git a/packages/ai/test/issue-830-repro.test.ts b/packages/ai/test/issue-830-repro.test.ts index b0f9e1d91..4a6e53cb1 100644 --- a/packages/ai/test/issue-830-repro.test.ts +++ b/packages/ai/test/issue-830-repro.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "../src/provider-models/descriptors"; -import { MODELS_DEV_PROVIDER_DESCRIPTORS } from "../src/provider-models/openai-compat"; -import { getEnvApiKey } from "../src/stream"; -import type { OpenAICompat } from "../src/types"; -import { getOAuthProviders } from "../src/utils/oauth"; +import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-ai/provider-models/descriptors"; +import { MODELS_DEV_PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; +import { getOAuthProviders } from "@oh-my-pi/pi-ai/registry/oauth"; +import { getEnvApiKey } from "@oh-my-pi/pi-ai/stream"; +import type { OpenAICompat } from "@oh-my-pi/pi-ai/types"; describe("deepseek built-in provider (issue #830)", () => { test("registers built-in runtime descriptor with DEEPSEEK_API_KEY env discovery", () => { diff --git a/packages/ai/test/issue-847-repro.test.ts b/packages/ai/test/issue-847-repro.test.ts index 684792ee2..fccf56034 100644 --- a/packages/ai/test/issue-847-repro.test.ts +++ b/packages/ai/test/issue-847-repro.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test, vi } from "bun:test"; -import { ollamaModelManagerOptions } from "../src/provider-models/openai-compat"; +import { ollamaModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; const originalFetch = global.fetch; diff --git a/packages/ai/test/issue-883-repro.test.ts b/packages/ai/test/issue-883-repro.test.ts index 236a99f2b..c0710ecb0 100644 --- a/packages/ai/test/issue-883-repro.test.ts +++ b/packages/ai/test/issue-883-repro.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { convertMessages, detectCompat } from "../src/providers/openai-completions"; -import type { AssistantMessage, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { convertMessages, detectCompat } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { AssistantMessage, Model } from "@oh-my-pi/pi-ai/types"; function deepseekModel(overrides: Partial>): Model<"openai-completions"> { return { diff --git a/packages/ai/test/issue-887-repro.test.ts b/packages/ai/test/issue-887-repro.test.ts index 6e512b28c..463a3e56d 100644 --- a/packages/ai/test/issue-887-repro.test.ts +++ b/packages/ai/test/issue-887-repro.test.ts @@ -13,7 +13,7 @@ import { MODELS_DEV_PROVIDER_DESCRIPTORS, type ModelsDevModel, opencodeGoModelManagerOptions, -} from "../src/provider-models/openai-compat"; +} from "@oh-my-pi/pi-ai/provider-models/openai-compat"; const OPENCODE_GO_BASE = "https://opencode.ai/zen/go/v1"; diff --git a/packages/ai/test/issue-911-repro.test.ts b/packages/ai/test/issue-911-repro.test.ts index 4ca61123f..f039c8e58 100644 --- a/packages/ai/test/issue-911-repro.test.ts +++ b/packages/ai/test/issue-911-repro.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import type { Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/issue-957-repro.test.ts b/packages/ai/test/issue-957-repro.test.ts index 91048b695..d26e1f9ea 100644 --- a/packages/ai/test/issue-957-repro.test.ts +++ b/packages/ai/test/issue-957-repro.test.ts @@ -2,8 +2,8 @@ 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 { AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; -import * as kimiOauth from "../src/utils/oauth/kimi"; +import { AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; +import * as kimiOauth from "@oh-my-pi/pi-ai/registry/oauth/kimi"; const originalFetch = global.fetch; diff --git a/packages/ai/test/issue-959-repro.test.ts b/packages/ai/test/issue-959-repro.test.ts index e652e06a1..87fe2219c 100644 --- a/packages/ai/test/issue-959-repro.test.ts +++ b/packages/ai/test/issue-959-repro.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import type { Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/issue-967-vision-guard.test.ts b/packages/ai/test/issue-967-vision-guard.test.ts index c6a127dc6..607b7d9bf 100644 --- a/packages/ai/test/issue-967-vision-guard.test.ts +++ b/packages/ai/test/issue-967-vision-guard.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it } from "bun:test"; -import { convertAnthropicMessages } from "../src/providers/anthropic"; -import { convertMessages as convertGoogleMessages } from "../src/providers/google-shared"; -import { convertCodexResponsesMessages } from "../src/providers/openai-codex-responses"; -import { convertMessages as convertOpenAICompletionsMessages } from "../src/providers/openai-completions"; -import type { ResolvedOpenAICompat } from "../src/providers/openai-completions-compat"; +import { convertAnthropicMessages } from "@oh-my-pi/pi-ai/providers/anthropic"; +import { convertMessages as convertGoogleMessages } from "@oh-my-pi/pi-ai/providers/google-shared"; +import { convertCodexResponsesMessages } from "@oh-my-pi/pi-ai/providers/openai-codex-responses"; +import { convertMessages as convertOpenAICompletionsMessages } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { ResolvedOpenAICompat } from "@oh-my-pi/pi-ai/providers/openai-completions-compat"; import { appendResponsesToolResultMessages, convertResponsesInputContent, -} from "../src/providers/openai-responses-shared"; -import { NON_VISION_IMAGE_PLACEHOLDER } from "../src/providers/vision-guard"; -import type { Api, AssistantMessage, Context, Model, ToolResultMessage, Usage } from "../src/types"; +} from "@oh-my-pi/pi-ai/providers/openai-responses-shared"; +import { NON_VISION_IMAGE_PLACEHOLDER } from "@oh-my-pi/pi-ai/providers/vision-guard"; +import type { Api, AssistantMessage, Context, Model, ToolResultMessage, Usage } from "@oh-my-pi/pi-ai/types"; const emptyUsage: Usage = { input: 0, diff --git a/packages/ai/test/issue-969-repro.test.ts b/packages/ai/test/issue-969-repro.test.ts index 9f42a85bb..c33749f92 100644 --- a/packages/ai/test/issue-969-repro.test.ts +++ b/packages/ai/test/issue-969-repro.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { Effort } from "../src/effort"; -import { getSupportedEfforts } from "../src/model-thinking"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import type { Context, Model } from "../src/types"; +import { Effort } from "@oh-my-pi/pi-ai/effort"; +import { getSupportedEfforts } from "@oh-my-pi/pi-ai/model-thinking"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/issue-976-repro.test.ts b/packages/ai/test/issue-976-repro.test.ts index 5e088d0f9..743358a06 100644 --- a/packages/ai/test/issue-976-repro.test.ts +++ b/packages/ai/test/issue-976-repro.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { buildRequest } from "../src/providers/google-gemini-cli"; -import type { Context, Model } from "../src/types"; +import { buildRequest } from "@oh-my-pi/pi-ai/providers/google-gemini-cli"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; function createModel(): Model<"google-gemini-cli"> { return { diff --git a/packages/ai/test/kagi-login.test.ts b/packages/ai/test/kagi-login.test.ts index 37686a0b4..1bbe75f6c 100644 --- a/packages/ai/test/kagi-login.test.ts +++ b/packages/ai/test/kagi-login.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { loginKagi } from "../src/utils/oauth/kagi"; +import { loginKagi } from "@oh-my-pi/pi-ai/registry/kagi"; describe("kagi login", () => { it("opens Kagi API settings and prompts for key", async () => { diff --git a/packages/ai/test/kilo-login.test.ts b/packages/ai/test/kilo-login.test.ts index 3364e5529..23e22d91d 100644 --- a/packages/ai/test/kilo-login.test.ts +++ b/packages/ai/test/kilo-login.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { loginKilo } from "../src/utils/oauth/kilo"; +import { loginKilo } from "@oh-my-pi/pi-ai/registry/kilo"; const originalFetch = global.fetch; diff --git a/packages/ai/test/minimax-code-login.test.ts b/packages/ai/test/minimax-code-login.test.ts index 8baa260fb..754783112 100644 --- a/packages/ai/test/minimax-code-login.test.ts +++ b/packages/ai/test/minimax-code-login.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; +import { loginMiniMaxCode, loginMiniMaxCodeCn } from "@oh-my-pi/pi-ai/registry/oauth/minimax-code"; import { hookFetch } from "@oh-my-pi/pi-utils"; -import { loginMiniMaxCode, loginMiniMaxCodeCn } from "../src/utils/oauth/minimax-code"; describe("MiniMax Coding Plan login", () => { it("opens the international platform and validates against the international API", async () => { diff --git a/packages/ai/test/mock-provider.test.ts b/packages/ai/test/mock-provider.test.ts index bdd189b3a..249b6edf8 100644 --- a/packages/ai/test/mock-provider.test.ts +++ b/packages/ai/test/mock-provider.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { clearCustomApis, getCustomApi } from "../src/api-registry"; +import { clearCustomApis, getCustomApi } from "@oh-my-pi/pi-ai/api-registry"; import { createMockModel, isMockModel, @@ -8,8 +8,8 @@ import { type MockModel, registerMockApi, streamMock, -} from "../src/providers/mock"; -import type { AssistantMessage, AssistantMessageEvent, Context, ToolCall } from "../src/types"; +} from "@oh-my-pi/pi-ai/providers/mock"; +import type { AssistantMessage, AssistantMessageEvent, Context, ToolCall } from "@oh-my-pi/pi-ai/types"; afterEach(() => { clearCustomApis(); diff --git a/packages/ai/test/model-cache.test.ts b/packages/ai/test/model-cache.test.ts index 7ebd3a45f..25353f68a 100644 --- a/packages/ai/test/model-cache.test.ts +++ b/packages/ai/test/model-cache.test.ts @@ -3,8 +3,8 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { readModelCache, writeModelCache } from "../src/model-cache"; -import type { Model } from "../src/types"; +import { readModelCache, writeModelCache } from "@oh-my-pi/pi-ai/model-cache"; +import type { Model } from "@oh-my-pi/pi-ai/types"; const TTL_MS = 24 * 60 * 60 * 1000; diff --git a/packages/ai/test/models-cost.test.ts b/packages/ai/test/models-cost.test.ts index 07b80d523..b8787fa49 100644 --- a/packages/ai/test/models-cost.test.ts +++ b/packages/ai/test/models-cost.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { calculateCost, getBundledModel } from "../src/models"; -import type { Usage } from "../src/types"; +import { calculateCost, getBundledModel } from "@oh-my-pi/pi-ai/models"; +import type { Usage } from "@oh-my-pi/pi-ai/types"; describe("calculateCost", () => { it("keeps token-based calculation for GitHub Copilot models", () => { diff --git a/packages/ai/test/models-json-no-local-endpoints.test.ts b/packages/ai/test/models-json-no-local-endpoints.test.ts index 614bb135b..0757d26a6 100644 --- a/packages/ai/test/models-json-no-local-endpoints.test.ts +++ b/packages/ai/test/models-json-no-local-endpoints.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; +import type { Model } from "@oh-my-pi/pi-ai/types"; import MODELS_JSON from "../src/models.json" with { type: "json" }; -import type { Model } from "../src/types"; // Pins the invariant: the committed `models.json` must never carry a // local/self-hosted provider's catalog. Those providers default to an endpoint diff --git a/packages/ai/test/nanogpt-login.test.ts b/packages/ai/test/nanogpt-login.test.ts index 601a10e83..0b301b18d 100644 --- a/packages/ai/test/nanogpt-login.test.ts +++ b/packages/ai/test/nanogpt-login.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { loginNanoGPT } from "../src/utils/oauth/nanogpt"; +import { loginNanoGPT } from "@oh-my-pi/pi-ai/registry/nanogpt"; const originalFetch = global.fetch; diff --git a/packages/ai/test/nanogpt-model-limits.test.ts b/packages/ai/test/nanogpt-model-limits.test.ts index 0270b18ff..512a74970 100644 --- a/packages/ai/test/nanogpt-model-limits.test.ts +++ b/packages/ai/test/nanogpt-model-limits.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { Effort } from "../src/effort"; -import { nanoGptModelManagerOptions } from "../src/provider-models/openai-compat"; +import { Effort } from "@oh-my-pi/pi-ai/effort"; +import { nanoGptModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; const originalFetch = global.fetch; diff --git a/packages/ai/test/oauth-deepseek.test.ts b/packages/ai/test/oauth-deepseek.test.ts index 8e867ca62..3ecf301be 100644 --- a/packages/ai/test/oauth-deepseek.test.ts +++ b/packages/ai/test/oauth-deepseek.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { loginDeepSeek, normalizeDeepSeekApiKey } from "../src/utils/oauth/deepseek"; -import type { OAuthController } from "../src/utils/oauth/types"; +import { loginDeepSeek, normalizeDeepSeekApiKey } from "@oh-my-pi/pi-ai/registry/deepseek"; +import type { OAuthController } from "@oh-my-pi/pi-ai/registry/oauth/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/oauth.ts b/packages/ai/test/oauth.ts index af2f52dfe..578daf588 100644 --- a/packages/ai/test/oauth.ts +++ b/packages/ai/test/oauth.ts @@ -8,8 +8,8 @@ */ import * as fs from "node:fs/promises"; import path from "node:path"; -import { getOAuthApiKey } from "@oh-my-pi/pi-ai/utils/oauth"; -import type { OAuthCredentials, OAuthProvider } from "@oh-my-pi/pi-ai/utils/oauth/types"; +import { getOAuthApiKey } from "@oh-my-pi/pi-ai/oauth"; +import type { OAuthCredentials, OAuthProvider } from "@oh-my-pi/pi-ai/oauth/types"; import { $flag, getAgentDir, isEnoent } from "@oh-my-pi/pi-utils"; /** diff --git a/packages/ai/test/ollama-cloud-login.test.ts b/packages/ai/test/ollama-cloud-login.test.ts index 18c3ce505..a6b1a2b54 100644 --- a/packages/ai/test/ollama-cloud-login.test.ts +++ b/packages/ai/test/ollama-cloud-login.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { loginOllamaCloud } from "../src/utils/oauth/ollama-cloud"; +import { loginOllamaCloud } from "@oh-my-pi/pi-ai/registry/ollama-cloud"; describe("ollama cloud login", () => { it("opens Ollama Cloud key settings and trims the pasted key", async () => { diff --git a/packages/ai/test/ollama-cloud-provider.test.ts b/packages/ai/test/ollama-cloud-provider.test.ts index cf5c6c383..033b7b8de 100644 --- a/packages/ai/test/ollama-cloud-provider.test.ts +++ b/packages/ai/test/ollama-cloud-provider.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test, vi } from "bun:test"; -import { ollamaCloudModelManagerOptions } from "../src/provider-models/ollama"; -import { completeSimple, getEnvApiKey, stream, streamSimple } from "../src/stream"; -import type { Context, Model, Tool } from "../src/types"; +import { ollamaCloudModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/ollama"; +import { completeSimple, getEnvApiKey, stream, streamSimple } from "@oh-my-pi/pi-ai/stream"; +import type { Context, Model, Tool } from "@oh-my-pi/pi-ai/types"; const originalApiKey = Bun.env.OLLAMA_CLOUD_API_KEY; const originalFetch = global.fetch; diff --git a/packages/ai/test/ollama-provider.test.ts b/packages/ai/test/ollama-provider.test.ts index 4fa4866c2..ccf7ec666 100644 --- a/packages/ai/test/ollama-provider.test.ts +++ b/packages/ai/test/ollama-provider.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, test, vi } from "bun:test"; -import { Effort } from "../src/effort"; -import { ollamaModelManagerOptions } from "../src/provider-models/openai-compat"; -import { streamOllama } from "../src/providers/ollama"; -import type { Context, Model, Tool } from "../src/types"; +import { Effort } from "@oh-my-pi/pi-ai/effort"; +import { ollamaModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; +import { streamOllama } from "@oh-my-pi/pi-ai/providers/ollama"; +import type { Context, Model, Tool } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index 3e7bc30f9..2904b6a10 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -2097,6 +2097,69 @@ describe("openai-codex streaming", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("interrupts websocket streams that emit only whitespace tool-call argument deltas", async () => { + const tempDir = TempDir.createSync("@pi-codex-stream-"); + setAgentDir(tempDir.path()); + const token = createCodexTestToken(); + const fetchMock = vi.fn(async () => { + throw new Error("SSE fallback should not run for degenerate tool-call arguments"); + }); + global.fetch = fetchMock as unknown as typeof fetch; + + let sendCount = 0; + let closeCount = 0; + class WhitespaceArgumentsWebSocket extends MockWebSocket { + constructor(url: string, options?: { headers?: WsHeaders }) { + super(url, options); + this.scheduleOpen(); + } + + send(): void { + sendCount += 1; + this.sendJson({ + type: "response.output_item.added", + item: { + type: "function_call", + id: "fc_ws_whitespace", + call_id: "call_ws_whitespace", + name: "todo", + arguments: "", + }, + }); + for (let sequence = 1; sequence <= 300; sequence += 1) { + this.sendJson({ + type: "response.function_call_arguments.delta", + delta: sequence % 2 === 0 ? " ".repeat(64) : "\t", + item_id: "fc_ws_whitespace", + output_index: 1, + sequence_number: sequence, + }); + } + } + + close(): void { + closeCount += 1; + super.close(); + } + } + global.WebSocket = WhitespaceArgumentsWebSocket as unknown as typeof WebSocket; + + const model = createCodexTestModel("https://chatgpt.com/backend-api"); + const providerSessionState = new Map(); + const result = await streamOpenAICodexResponses(model, createCodexTestContext(), { + apiKey: token, + sessionId: "ws-whitespace-arguments-session", + providerSessionState, + }).result(); + + expect(sendCount).toBe(1); + expect(closeCount).toBeGreaterThan(0); + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toContain("whitespace-only tool-call argument delta"); + expect(result.errorMessage).toContain("fc_ws_whitespace"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("retries websocket stream closes before surfacing transport errors", async () => { const tempDir = TempDir.createSync("@pi-codex-stream-"); setAgentDir(tempDir.path()); diff --git a/packages/ai/test/openai-codex-usage.test.ts b/packages/ai/test/openai-codex-usage.test.ts index 626646402..f7f927a9d 100644 --- a/packages/ai/test/openai-codex-usage.test.ts +++ b/packages/ai/test/openai-codex-usage.test.ts @@ -7,7 +7,7 @@ * widget lose per-model visibility. */ import { describe, expect, it } from "bun:test"; -import { openaiCodexUsageProvider } from "../src/usage/openai-codex"; +import { openaiCodexUsageProvider } from "@oh-my-pi/pi-ai/usage/openai-codex"; const accessTokenFixture = (() => { const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts index da047b421..c26b35bc4 100644 --- a/packages/ai/test/openai-completions-compat.test.ts +++ b/packages/ai/test/openai-completions-compat.test.ts @@ -1,13 +1,13 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { applyOpenRouterRoutingVariant, convertMessages, detectCompat, streamOpenAICompletions, -} from "../src/providers/openai-completions"; -import { type ResolvedOpenAICompat, resolveOpenAICompat } from "../src/providers/openai-completions-compat"; -import type { AssistantMessage, Context, Model, OpenAICompat } from "../src/types"; +} from "@oh-my-pi/pi-ai/providers/openai-completions"; +import { type ResolvedOpenAICompat, resolveOpenAICompat } from "@oh-my-pi/pi-ai/providers/openai-completions-compat"; +import type { AssistantMessage, Context, Model, OpenAICompat } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/openai-completions-disable-reasoning.test.ts b/packages/ai/test/openai-completions-disable-reasoning.test.ts index 629a74887..4d3e4d19c 100644 --- a/packages/ai/test/openai-completions-disable-reasoning.test.ts +++ b/packages/ai/test/openai-completions-disable-reasoning.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { Effort } from "../src/effort"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import type { Context, Model } from "../src/types"; +import { Effort } from "@oh-my-pi/pi-ai/effort"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/openai-completions-progress-chunk.test.ts b/packages/ai/test/openai-completions-progress-chunk.test.ts index 7d6a7766f..db4b1058d 100644 --- a/packages/ai/test/openai-completions-progress-chunk.test.ts +++ b/packages/ai/test/openai-completions-progress-chunk.test.ts @@ -1,11 +1,11 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { getOpenAICompletionsStreamIdleTimeoutFallbackMs, isOpenAICompletionsProgressChunk, streamOpenAICompletions, -} from "../src/providers/openai-completions"; -import type { Context, Model } from "../src/types"; +} from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/openai-first-event-timeout.test.ts b/packages/ai/test/openai-first-event-timeout.test.ts index d23dc868f..4886cf79c 100644 --- a/packages/ai/test/openai-first-event-timeout.test.ts +++ b/packages/ai/test/openai-first-event-timeout.test.ts @@ -1,10 +1,10 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamAzureOpenAIResponses } from "../src/providers/azure-openai-responses"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import { streamOpenAIResponses } from "../src/providers/openai-responses"; -import { streamSimple } from "../src/stream"; -import type { Context, Model, TextContent } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamAzureOpenAIResponses } from "@oh-my-pi/pi-ai/providers/azure-openai-responses"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; +import { streamSimple } from "@oh-my-pi/pi-ai/stream"; +import type { Context, Model, TextContent } from "@oh-my-pi/pi-ai/types"; import { waitForDelayOrAbort } from "./helpers"; const originalFetch = global.fetch; diff --git a/packages/ai/test/openai-responses-cache-affinity.test.ts b/packages/ai/test/openai-responses-cache-affinity.test.ts index 9d9622502..8cd6d2fc7 100644 --- a/packages/ai/test/openai-responses-cache-affinity.test.ts +++ b/packages/ai/test/openai-responses-cache-affinity.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { type OpenAIResponsesOptions, streamOpenAIResponses } from "../src/providers/openai-responses"; -import type { Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { type OpenAIResponsesOptions, streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; const model = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">; diff --git a/packages/ai/test/openai-responses-developer-role.test.ts b/packages/ai/test/openai-responses-developer-role.test.ts index e4f028a2c..6789f2e3b 100644 --- a/packages/ai/test/openai-responses-developer-role.test.ts +++ b/packages/ai/test/openai-responses-developer-role.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { supportsDeveloperRole } from "../src/providers/openai-responses"; -import type { Model } from "../src/types"; +import { supportsDeveloperRole } from "@oh-my-pi/pi-ai/providers/openai-responses"; +import type { Model } from "@oh-my-pi/pi-ai/types"; describe("supportsDeveloperRole", () => { it("returns true for openai provider with official API base URL", () => { diff --git a/packages/ai/test/openai-responses-history-payload.test.ts b/packages/ai/test/openai-responses-history-payload.test.ts index 4b86ab9d1..6b5020d1d 100644 --- a/packages/ai/test/openai-responses-history-payload.test.ts +++ b/packages/ai/test/openai-responses-history-payload.test.ts @@ -3,7 +3,7 @@ import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamOpenAICodexResponses } from "@oh-my-pi/pi-ai/providers/openai-codex-responses"; import { type OpenAIResponsesOptions, streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; import type { Context, Model, ProviderSessionState } from "@oh-my-pi/pi-ai/types"; -import { createOpenAIResponsesHistoryPayload, truncateResponseItemId } from "../src/utils"; +import { createOpenAIResponsesHistoryPayload, truncateResponseItemId } from "@oh-my-pi/pi-ai/utils"; function createAbortedSignal(): AbortSignal { const controller = new AbortController(); diff --git a/packages/ai/test/openai-responses-omit-max-output-tokens.test.ts b/packages/ai/test/openai-responses-omit-max-output-tokens.test.ts index a5dcb1537..423ffa78d 100644 --- a/packages/ai/test/openai-responses-omit-max-output-tokens.test.ts +++ b/packages/ai/test/openai-responses-omit-max-output-tokens.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamSimple } from "../src/stream"; -import type { Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamSimple } from "@oh-my-pi/pi-ai/stream"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/openai-responses-system-prompt.test.ts b/packages/ai/test/openai-responses-system-prompt.test.ts index 0f459da8e..970d6567f 100644 --- a/packages/ai/test/openai-responses-system-prompt.test.ts +++ b/packages/ai/test/openai-responses-system-prompt.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamOpenAIResponses } from "../src/providers/openai-responses"; -import type { Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/openrouter-login.test.ts b/packages/ai/test/openrouter-login.test.ts index aa6557a14..f98dd21c2 100644 --- a/packages/ai/test/openrouter-login.test.ts +++ b/packages/ai/test/openrouter-login.test.ts @@ -1,8 +1,8 @@ import { Database } from "bun:sqlite"; import { afterEach, describe, expect, test, vi } from "bun:test"; -import { AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; -import { getEnvApiKey } from "../src/stream"; -import { getOAuthProviders } from "../src/utils/oauth"; +import { AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; +import { getOAuthProviders } from "@oh-my-pi/pi-ai/registry/oauth"; +import { getEnvApiKey } from "@oh-my-pi/pi-ai/stream"; const originalOpenRouterApiKey = Bun.env.OPENROUTER_API_KEY; const originalFetch = global.fetch; diff --git a/packages/ai/test/parse-streaming-json-throttled.test.ts b/packages/ai/test/parse-streaming-json-throttled.test.ts index 9cd74ab6b..efe81683c 100644 --- a/packages/ai/test/parse-streaming-json-throttled.test.ts +++ b/packages/ai/test/parse-streaming-json-throttled.test.ts @@ -3,7 +3,7 @@ import { parseStreamingJson, parseStreamingJsonThrottled, STREAMING_JSON_PARSE_MIN_GROWTH, -} from "../src/utils/json-parse"; +} from "@oh-my-pi/pi-ai/utils/json-parse"; describe("parseStreamingJsonThrottled (F5)", () => { it("parses the first non-empty buffer even when growth is below the threshold", () => { diff --git a/packages/ai/test/pi-native-client.test.ts b/packages/ai/test/pi-native-client.test.ts index 2c8627454..9ccb9e38f 100644 --- a/packages/ai/test/pi-native-client.test.ts +++ b/packages/ai/test/pi-native-client.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; -import { streamPiNative } from "../src/providers/pi-native-client"; -import type { AssistantMessage, AssistantMessageEvent, Context, FetchImpl, Model } from "../src/types"; +import { streamPiNative } from "@oh-my-pi/pi-ai/providers/pi-native-client"; +import type { AssistantMessage, AssistantMessageEvent, Context, FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; function sseBytes(events: AssistantMessageEvent[]): Uint8Array { const encoder = new TextEncoder(); diff --git a/packages/ai/test/provider-fetch-override.test.ts b/packages/ai/test/provider-fetch-override.test.ts index d16ddca8a..fdf174ed2 100644 --- a/packages/ai/test/provider-fetch-override.test.ts +++ b/packages/ai/test/provider-fetch-override.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import { streamOpenAIResponses } from "../src/providers/openai-responses"; -import type { Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/provider-registry.test.ts b/packages/ai/test/provider-registry.test.ts new file mode 100644 index 000000000..cd4391778 --- /dev/null +++ b/packages/ai/test/provider-registry.test.ts @@ -0,0 +1,102 @@ +import { Database } from "bun:sqlite"; +import { afterEach, describe, expect, test, vi } from "bun:test"; +import { AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; +import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-ai/provider-models/descriptors"; +import { PASTE_CODE_LOGIN_PROVIDERS } from "@oh-my-pi/pi-ai/registry"; +import { + getOAuthProviders, + refreshOAuthToken, + registerOAuthProvider, + unregisterOAuthProviders, +} from "@oh-my-pi/pi-ai/registry/oauth"; +import * as anthropicOauth from "@oh-my-pi/pi-ai/registry/oauth/anthropic"; +import type { OAuthCredentials, OAuthProvider } from "@oh-my-pi/pi-ai/registry/oauth/types"; +import { getEnvApiKey } from "@oh-my-pi/pi-ai/stream"; + +const FIXTURE_SOURCE = "provider-registry-test"; +const ENV_KEYS = ["ZENMUX_API_KEY", "EXA_API_KEY"] as const; +const originalEnv = new Map(ENV_KEYS.map(key => [key, Bun.env[key]])); + +afterEach(() => { + unregisterOAuthProviders(FIXTURE_SOURCE); + for (const key of ENV_KEYS) { + const original = originalEnv.get(key); + if (original === undefined) { + delete Bun.env[key]; + } else { + Bun.env[key] = original; + } + } + vi.restoreAllMocks(); +}); + +describe("provider registry derivation", () => { + test("descriptors are derived for standard model providers, excluding special-managed ones", () => { + const zenmux = PROVIDER_DESCRIPTORS.find(descriptor => descriptor.providerId === "zenmux"); + expect(zenmux).toBeDefined(); + expect(zenmux?.defaultModel).toBe("anthropic/claude-opus-4.6"); + // The derived factory carries the provider identity through. + expect(zenmux?.createModelManagerOptions({ apiKey: "k" }).providerId).toBe("zenmux"); + + // openai-codex is special-managed (bespoke runtime factory) → excluded from descriptors, + // but still a known model provider with a default. + expect(PROVIDER_DESCRIPTORS.some(descriptor => descriptor.providerId === "openai-codex")).toBe(false); + expect(DEFAULT_MODEL_PER_PROVIDER["openai-codex"]).toBe("gpt-5.4"); + // Login-only tools have no default model. + expect(DEFAULT_MODEL_PER_PROVIDER).not.toHaveProperty("kagi"); + }); + + test("env-key map merges registry defs with legacy non-provider keys", () => { + Bun.env.ZENMUX_API_KEY = "zenmux-env"; + Bun.env.EXA_API_KEY = "exa-env"; + expect(getEnvApiKey("zenmux")).toBe("zenmux-env"); + // Legacy search-tool key preserved (not a registry provider def). + expect(getEnvApiKey("exa")).toBe("exa-env"); + }); + + test("login list contains loginable providers and excludes env-only model providers", () => { + const ids = getOAuthProviders().map(provider => provider.id); + expect(ids).toContain("zenmux"); + expect(ids).toContain("kagi"); + // openai has no interactive login flow. + expect(ids).not.toContain("openai"); + }); + + test("paste-code login set is derived from pasteCodeFlow", () => { + expect([...PASTE_CODE_LOGIN_PROVIDERS].sort()).toEqual( + ["anthropic", "gitlab-duo", "google-antigravity", "google-gemini-cli", "openai-codex"].sort(), + ); + expect(PASTE_CODE_LOGIN_PROVIDERS.has("zenmux")).toBe(false); + }); + + test("refresh dispatch returns api-key providers unchanged and routes real refreshers", async () => { + const creds: OAuthCredentials = { refresh: "r", access: "a", expires: Date.now() + 60_000 }; + // zenmux has no refresher → returned as-is. + expect(await refreshOAuthToken("zenmux", creds)).toBe(creds); + + const refreshed: OAuthCredentials = { refresh: "r2", access: "a2", expires: Date.now() + 120_000 }; + const spy = vi.spyOn(anthropicOauth, "refreshAnthropicToken").mockResolvedValue(refreshed); + expect(await refreshOAuthToken("anthropic", creds)).toBe(refreshed); + expect(spy).toHaveBeenCalledWith("r"); + + await expect(refreshOAuthToken("nonexistent-provider" as OAuthProvider, creds)).rejects.toThrow( + "Unknown OAuth provider", + ); + }); + + test("login dispatcher handles runtime-registered extension providers", async () => { + const store = new SqliteAuthCredentialStore(new Database(":memory:")); + const storage = new AuthStorage(store); + await storage.reload(); + registerOAuthProvider({ + id: "fixture-x", + name: "Fixture X", + sourceId: FIXTURE_SOURCE, + login: async () => "fixture-key", + }); + + await storage.login("fixture-x", { onAuth: () => {}, onPrompt: async () => "" }); + + expect(store.getApiKey("fixture-x")).toBe("fixture-key"); + }); +}); diff --git a/packages/ai/test/provider-response.test.ts b/packages/ai/test/provider-response.test.ts index f4d3cb393..14d4571fe 100644 --- a/packages/ai/test/provider-response.test.ts +++ b/packages/ai/test/provider-response.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamSimple } from "../src/stream"; -import type { Context, Model, ProviderResponseMetadata } from "../src/types"; -import { normalizeProviderResponse, notifyProviderResponse } from "../src/utils/provider-response"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamSimple } from "@oh-my-pi/pi-ai/stream"; +import type { Context, Model, ProviderResponseMetadata } from "@oh-my-pi/pi-ai/types"; +import { normalizeProviderResponse, notifyProviderResponse } from "@oh-my-pi/pi-ai/utils/provider-response"; describe("provider response metadata", () => { it("normalizes response status, headers, and request id", () => { diff --git a/packages/ai/test/raw-sse-sdk-capture.test.ts b/packages/ai/test/raw-sse-sdk-capture.test.ts index 02ab86e03..9df20c855 100644 --- a/packages/ai/test/raw-sse-sdk-capture.test.ts +++ b/packages/ai/test/raw-sse-sdk-capture.test.ts @@ -1,12 +1,12 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamAnthropic } from "../src/providers/anthropic"; -import type { AnthropicMessagesClientLike } from "../src/providers/anthropic-client"; -import type { RawMessageStreamEvent } from "../src/providers/anthropic-wire"; -import { streamAzureOpenAIResponses } from "../src/providers/azure-openai-responses"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import { streamOpenAIResponses } from "../src/providers/openai-responses"; -import type { Context, Model, RawSseEvent } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic"; +import type { AnthropicMessagesClientLike } from "@oh-my-pi/pi-ai/providers/anthropic-client"; +import type { RawMessageStreamEvent } from "@oh-my-pi/pi-ai/providers/anthropic-wire"; +import { streamAzureOpenAIResponses } from "@oh-my-pi/pi-ai/providers/azure-openai-responses"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; +import type { Context, Model, RawSseEvent } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/register-builtins.test.ts b/packages/ai/test/register-builtins.test.ts index 8e6628786..9f7a6a389 100644 --- a/packages/ai/test/register-builtins.test.ts +++ b/packages/ai/test/register-builtins.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { setBedrockProviderModule, streamBedrock } from "../src/providers/register-builtins"; -import type { AssistantMessage, Context, Model } from "../src/types"; -import type { AssistantMessageEventStream } from "../src/utils/event-stream"; +import { setBedrockProviderModule, streamBedrock } from "@oh-my-pi/pi-ai/providers/register-builtins"; +import type { AssistantMessage, Context, Model } from "@oh-my-pi/pi-ai/types"; +import type { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; function createModel(): Model<"bedrock-converse-stream"> { return { diff --git a/packages/ai/test/remote-auth-store.test.ts b/packages/ai/test/remote-auth-store.test.ts index 7c9e6c13f..e1a6e2be6 100644 --- a/packages/ai/test/remote-auth-store.test.ts +++ b/packages/ai/test/remote-auth-store.test.ts @@ -10,8 +10,8 @@ import { RemoteAuthCredentialStore, SqliteAuthCredentialStore, startAuthBroker, -} from "../src"; -import * as oauthUtils from "../src/utils/oauth"; +} from "@oh-my-pi/pi-ai"; +import * as oauthUtils from "@oh-my-pi/pi-ai/registry/oauth"; const ANTHROPIC_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"] as const; const savedEnv: Partial> = {}; diff --git a/packages/ai/test/request-debug.test.ts b/packages/ai/test/request-debug.test.ts index dbe482054..22bbf57e7 100644 --- a/packages/ai/test/request-debug.test.ts +++ b/packages/ai/test/request-debug.test.ts @@ -2,12 +2,12 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { clearCustomApis, registerCustomApi } from "@oh-my-pi/pi-ai/api-registry"; +import { stream } from "@oh-my-pi/pi-ai/stream"; +import type { AssistantMessage, FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; +import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; +import { wrapFetchForRequestDebug } from "@oh-my-pi/pi-ai/utils/request-debug"; import { hookFetch } from "@oh-my-pi/pi-utils"; -import { clearCustomApis, registerCustomApi } from "../src/api-registry"; -import { stream } from "../src/stream"; -import type { AssistantMessage, FetchImpl, Model } from "../src/types"; -import { AssistantMessageEventStream } from "../src/utils/event-stream"; -import { wrapFetchForRequestDebug } from "../src/utils/request-debug"; const enc = new TextEncoder(); diff --git a/packages/ai/test/service-tier-premium-requests.test.ts b/packages/ai/test/service-tier-premium-requests.test.ts index ca4bb1f7c..43216b83b 100644 --- a/packages/ai/test/service-tier-premium-requests.test.ts +++ b/packages/ai/test/service-tier-premium-requests.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { getPriorityPremiumRequests, resolveServiceTier, shouldSendServiceTier } from "../src/types"; +import { getPriorityPremiumRequests, resolveServiceTier, shouldSendServiceTier } from "@oh-my-pi/pi-ai/types"; describe("getPriorityPremiumRequests", () => { it("counts priority tier as one premium request on OpenAI", () => { diff --git a/packages/ai/test/sse-debug.test.ts b/packages/ai/test/sse-debug.test.ts index bc839265b..00dc71881 100644 --- a/packages/ai/test/sse-debug.test.ts +++ b/packages/ai/test/sse-debug.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import type { RawSseEvent } from "../src/types"; -import { notifyRawSseEvent } from "../src/utils/sse-debug"; +import type { RawSseEvent } from "@oh-my-pi/pi-ai/types"; +import { notifyRawSseEvent } from "@oh-my-pi/pi-ai/utils/sse-debug"; describe("notifyRawSseEvent", () => { it("dispatches diagnostic events without cloning raw lines", () => { diff --git a/packages/ai/test/stream-markup-healing.test.ts b/packages/ai/test/stream-markup-healing.test.ts index 79b6b74b5..c7e6facac 100644 --- a/packages/ai/test/stream-markup-healing.test.ts +++ b/packages/ai/test/stream-markup-healing.test.ts @@ -1,9 +1,9 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import { stream } from "../src/stream"; -import type { Context, Model, Tool, ToolCall } from "../src/types"; -import { getStreamMarkupHealingPattern, StreamMarkupHealing } from "../src/utils/stream-markup-healing"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import { stream } from "@oh-my-pi/pi-ai/stream"; +import type { Context, Model, Tool, ToolCall } from "@oh-my-pi/pi-ai/types"; +import { getStreamMarkupHealingPattern, StreamMarkupHealing } from "@oh-my-pi/pi-ai/utils/stream-markup-healing"; const originalFetch = global.fetch; diff --git a/packages/ai/test/stream-timeout-defaults.test.ts b/packages/ai/test/stream-timeout-defaults.test.ts index 5920ef978..3a75fb814 100644 --- a/packages/ai/test/stream-timeout-defaults.test.ts +++ b/packages/ai/test/stream-timeout-defaults.test.ts @@ -5,7 +5,7 @@ import { getStreamFirstEventTimeoutMs, getStreamIdleTimeoutMs, iterateWithIdleTimeout, -} from "../src/utils/idle-iterator"; +} from "@oh-my-pi/pi-ai/utils/idle-iterator"; /** * Per-provider fallback overrides on the stream-watchdog helpers. @@ -227,4 +227,33 @@ describe("iterateWithIdleTimeout", () => { await Bun.sleep(20); expect(firstItemTimedOut).toBe(false); }); + + it("closes the upstream iterator when the consumer breaks early", async () => { + let upstreamClosed = false; + async function* source(): AsyncGenerator { + try { + let n = 0; + while (true) { + await Bun.sleep(1); + yield `item-${n++}`; + } + } finally { + // Runs only if the wrapper forwards `.return()` to us. + upstreamClosed = true; + } + } + + for await (const _item of iterateWithIdleTimeout(source(), { + idleTimeoutMs: 1_000, + errorMessage: "idle timeout", + })) { + break; // abandon the wrapper after the first item + } + + // The wrapper must propagate the consumer's early termination to the source + // so the underlying SSE body / SDK stream (and its socket) is released + // instead of being left suspended. + await Bun.sleep(5); + expect(upstreamClosed).toBe(true); + }); }); diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 3d5325d42..16b08a72d 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -5,11 +5,11 @@ import * as os from "node:os"; import * as path from "node:path"; import { Effort } from "@oh-my-pi/pi-ai"; import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { __resetVertexTokenCache } from "@oh-my-pi/pi-ai/providers/google-auth"; import { complete, getEnvApiKey, stream } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, ImageContent, Model, OptionsForApi, Tool, ToolResultMessage } from "@oh-my-pi/pi-ai/types"; import { $which } from "@oh-my-pi/pi-utils"; import * as z from "zod/v4"; -import { __resetVertexTokenCache } from "../src/providers/google-auth"; import { e2eApiKey, resolveApiKey } from "./oauth"; // Resolve OAuth tokens at module level (async, runs before tests) diff --git a/packages/ai/test/synthetic-login.test.ts b/packages/ai/test/synthetic-login.test.ts index 744c14419..2820360dd 100644 --- a/packages/ai/test/synthetic-login.test.ts +++ b/packages/ai/test/synthetic-login.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { loginSynthetic } from "../src/utils/oauth/synthetic"; +import { loginSynthetic } from "@oh-my-pi/pi-ai/registry/synthetic"; const originalFetch = global.fetch; diff --git a/packages/ai/test/utils-responses-id.test.ts b/packages/ai/test/utils-responses-id.test.ts index 2b2066780..c400c7709 100644 --- a/packages/ai/test/utils-responses-id.test.ts +++ b/packages/ai/test/utils-responses-id.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { normalizeResponsesToolCallId } from "../src/utils"; +import { normalizeResponsesToolCallId } from "@oh-my-pi/pi-ai/utils"; describe("normalizeResponsesToolCallId", () => { it("preserves existing item prefix when truncating oversized ids", () => { diff --git a/packages/ai/test/wafer.live.ts b/packages/ai/test/wafer.live.ts index f8dcd8caa..33f7fd323 100644 --- a/packages/ai/test/wafer.live.ts +++ b/packages/ai/test/wafer.live.ts @@ -7,9 +7,9 @@ * `model` field preserved verbatim (`GLM-5.1`, not lowercased) and a non-empty * assistant text returned. */ -import { getBundledModel } from "../src/models"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import type { Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const apiKey = process.env.WAFER_PASS_API_KEY ?? process.env.WAFER_SERVERLESS_API_KEY; if (!apiKey) { diff --git a/packages/ai/test/wafer.test.ts b/packages/ai/test/wafer.test.ts index 29e9b491f..a7deb0100 100644 --- a/packages/ai/test/wafer.test.ts +++ b/packages/ai/test/wafer.test.ts @@ -11,11 +11,14 @@ * the case-sensitive id pass-through against the wire. */ import { afterEach, describe, expect, it } from "bun:test"; -import { createModelManager } from "../src/model-manager"; -import { getBundledModel } from "../src/models"; -import { waferPassModelManagerOptions, waferServerlessModelManagerOptions } from "../src/provider-models/openai-compat"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import type { Context, Model } from "../src/types"; +import { createModelManager } from "@oh-my-pi/pi-ai/model-manager"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { + waferPassModelManagerOptions, + waferServerlessModelManagerOptions, +} from "@oh-my-pi/pi-ai/provider-models/openai-compat"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/xai-oauth-bundle.test.ts b/packages/ai/test/xai-oauth-bundle.test.ts index 824b2babe..c688ca1fc 100644 --- a/packages/ai/test/xai-oauth-bundle.test.ts +++ b/packages/ai/test/xai-oauth-bundle.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; +import { buildXaiOAuthStaticSeed } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; +import type { Model } from "@oh-my-pi/pi-ai/types"; import MODELS_JSON from "../src/models.json" with { type: "json" }; -import { buildXaiOAuthStaticSeed } from "../src/provider-models/openai-compat"; -import type { Model } from "../src/types"; // Pins the invariant: bundled `models.json` carries every entry the runtime // curated catalog (XAI_OAUTH_CURATED_MODELS, surfaced via diff --git a/packages/ai/test/xai-oauth-effort-strip.test.ts b/packages/ai/test/xai-oauth-effort-strip.test.ts index 11b49542f..2239abadb 100644 --- a/packages/ai/test/xai-oauth-effort-strip.test.ts +++ b/packages/ai/test/xai-oauth-effort-strip.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { modelOmitsReasoningEffort } from "@oh-my-pi/pi-ai/model-thinking"; import { getBundledModel } from "@oh-my-pi/pi-ai/models"; -import { modelOmitsReasoningEffort } from "../src/model-thinking"; // Pins fix #2 of the compaction effort-override bug. Before this fix, // `resolveOpenAiReasoningEffort` called `requireSupportedEffort` which threw diff --git a/packages/ai/test/xiaomi-oauth.test.ts b/packages/ai/test/xiaomi-oauth.test.ts index 7f5e0c9f4..11ffcbb21 100644 --- a/packages/ai/test/xiaomi-oauth.test.ts +++ b/packages/ai/test/xiaomi-oauth.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { loginXiaomi } from "../src/utils/oauth/xiaomi"; +import { loginXiaomi } from "@oh-my-pi/pi-ai/registry/oauth/xiaomi"; const originalFetch = global.fetch; diff --git a/packages/ai/test/xiaomi-tp-login-integration.test.ts b/packages/ai/test/xiaomi-tp-login-integration.test.ts index 1711bfd47..fe1fdb6a0 100644 --- a/packages/ai/test/xiaomi-tp-login-integration.test.ts +++ b/packages/ai/test/xiaomi-tp-login-integration.test.ts @@ -14,11 +14,10 @@ */ import { describe, expect, it } from "bun:test"; +import { xiaomiModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; +import { loginXiaomi } from "@oh-my-pi/pi-ai/registry/oauth/xiaomi"; import { hookFetch } from "@oh-my-pi/pi-utils"; -import { xiaomiModelManagerOptions } from "../src/provider-models/openai-compat"; -import { loginXiaomi } from "../src/utils/oauth/xiaomi"; - // Realistic tp- key (same format as user's key, but a dummy value for testing) const TP_KEY = "tp-ci1p8t1w4e1sbxgyc8v65tnrjbzro287igmvyf25van9mt76"; diff --git a/packages/ai/test/zenmux-login.test.ts b/packages/ai/test/zenmux-login.test.ts index 19429fe1e..7186c3b05 100644 --- a/packages/ai/test/zenmux-login.test.ts +++ b/packages/ai/test/zenmux-login.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { loginZenMux } from "../src/utils/oauth/zenmux"; +import { loginZenMux } from "@oh-my-pi/pi-ai/registry/zenmux"; const originalFetch = global.fetch; diff --git a/packages/ai/test/zenmux-provider.test.ts b/packages/ai/test/zenmux-provider.test.ts index 05e6de679..e11bf9af5 100644 --- a/packages/ai/test/zenmux-provider.test.ts +++ b/packages/ai/test/zenmux-provider.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, test, vi } from "bun:test"; -import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "../src/provider-models/descriptors"; -import { zenmuxModelManagerOptions } from "../src/provider-models/openai-compat"; -import { getEnvApiKey } from "../src/stream"; -import { getOAuthProviders } from "../src/utils/oauth"; +import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-ai/provider-models/descriptors"; +import { zenmuxModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; +import { getOAuthProviders } from "@oh-my-pi/pi-ai/registry/oauth"; +import { getEnvApiKey } from "@oh-my-pi/pi-ai/stream"; const originalZenMuxApiKey = Bun.env.ZENMUX_API_KEY; const originalFetch = global.fetch; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9660a7545..52f87a8c3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,58 @@ - Added isolated profile support via `--profile ` / `OMP_PROFILE` and shell alias bootstrap via `--alias `, including launch/ACP bootstrap handling, extension-flag-safe parsing, profile-scoped user config discovery, and symlinked extension-directory discovery. +## [15.10.5] - 2026-06-08 + +### Added + +- Added Homebrew and mise package-manager update paths to the self-update command so installations launched from those tools are updated through their native workflows +- Added detection of Homebrew and mise install locations so self-update chooses the manager-specific updater when the active `omp` binary comes from a package-manager-managed path +- Added `astCondition` to TTSR rule frontmatter as a syntax-aware alternative to regex `condition`, enabling AST-based matching for edit/write tool snapshots +- Added a built-in `ts-redundant-clear-guard` rule that flags redundant guards around `clearTimeout`, `clearInterval`, and `clearImmediate` calls +- Added a built-in `ts-no-test-timers` rule that flags real timers (`Bun.sleep`, `setTimeout`, `setInterval`) in `*.test.ts` files, steering toward fake timers (`vi.useFakeTimers()` / `vi.advanceTimersByTime()`) +- Added support for paste marker highlighting with accent styling (`[Paste #N, +X lines]`/`[Paste #N, Y chars]`) in the prompt editor, matching the visual treatment of image references +- Added pixel dimensions to pasted/loaded image placeholders in the prompt — the marker now reads `[Image #N, WxH]` (falling back to `[Image #N]` when the header can't be decoded). +- The bundled shell now treats `nohup` as a builtin: `nohup … &` runs the command without masking `SIGHUP` or detaching it, so agent-started daemons stay tied to this agent's lifetime instead of leaking as orphans when the agent exits. Updated the bash tool prompt's daemon guidance to match (dropped the `nohup … & / setsid … & / disown` detach recommendation in favor of a large `timeout` plus the persistent session). +- Added per-tool `tool.*` theme symbol keys (nerd/unicode/ascii presets) plus a quiet `status.done` glyph, so each tool's result header can carry a signature icon instead of a generic status mark + +### Changed + +- Updated pi-ai OAuth imports to the renamed `@oh-my-pi/pi-ai/oauth` subpath (was `@oh-my-pi/pi-ai/utils/oauth`) across the login UI, MCP OAuth flow, model registry, setup wizard, and web-search Codex auth. The legacy-plugin specifier shim drops its `pi-ai/oauth` → `pi-ai/utils/oauth` subpath rewrite, since the canonical `@oh-my-pi/pi-ai/oauth` export now resolves directly. +- Changed forced self-updates for Homebrew installs to run `brew reinstall` and for mise installs to run `mise install --force` after `mise upgrade` when `--force` is requested +- Changed TTSR rule bucketing and matching so rules with only `astCondition` are treated as TTSR rules and evaluated in the interrupt flow using reconstructed edit/write source snapshots +- Normalized image content before it enters model context so attached images are downscaled and preprocessed for prompts, steering messages, follow-ups, and custom agent messages +- Changed image marker format to include pixel dimensions when available (`[Image #N, WxH]`), falling back to bare `[Image #N]` when header cannot be decoded +- Changed the prompt editor to highlight large-paste placeholders (`[Paste #N, +X lines]`/`[Paste #N, Y chars]`) with the same accent styling as image references (bold, no hyperlink), and to delete image/paste markers atomically: a single backspace or forward-delete removes the whole marker instead of leaving a broken `[Paste #N, +X lines` behind. +- Browser tool helpers (`tab.*`) are now individually tracked and time-bounded: when a `run` cell hits its budget, the timeout error names the still-running helper(s) and how long each has been stalled (e.g. `... (stalled on tab.screenshot({ selector: ".x" }) (29.9s))`) instead of the opaque `Browser code execution timed out after 30000ms`. Page-coupled helpers that should resolve quickly (`observe`, `screenshot`, `extract`) also fail fast with a named per-op error at `min(cellBudget, 20s)`, leaving budget for the rest of the cell, rather than silently consuming the whole budget. +- Derived the auth-broker OAuth callback ports (`CALLBACK_PORTS`) and the paste-code login-provider set from the `@oh-my-pi/pi-ai` provider registry, removing the duplicated `CALLBACK_SERVER_PROVIDERS` tables in the model selector and the setup-wizard sign-in scene. +- Raised the `eval` tool's per-cell `timeout` ceiling from 600s to 3600s (matching `bash`), in both the Zod schema and the `TOOL_TIMEOUTS.eval` runtime clamp, so heavy local-compute cells can request budgets above 10 minutes. +- Derived the auth-broker OAuth callback ports (`CALLBACK_PORTS`) and the paste-code login-provider set from the `@oh-my-pi/pi-ai` provider registry, removing the duplicated `CALLBACK_SERVER_PROVIDERS` tables in the model selector and the setup-wizard sign-in scene. +- Reworked tool result-header glyphs to cut the overused success checkmark/dot: each tool now shows its own signature icon on success (terminal for bash, pencil for edit, magnifier/globe for search, plug for MCP, etc.; read keeps the read-group status dot), tools without a custom renderer fall back to a quiet `status.done` dot, and error/warning/pending states keep the universal cross/warning/spinner +- Changed steady-state health indicators (LSP server ready, OAuth logged-in, plugin-doctor checks) from a success checkmark to a colored `status.enabled` dot, so failures stand out instead of every line reading as a check +- Changed one-shot MCP/SSH/debug confirmation messages from a generic checkmark to contextual action glyphs (add/remove, connect/enable/disable toggles, reload, job-completed), reflecting what happened rather than just "success" +- Derived the auth-broker OAuth callback ports (`CALLBACK_PORTS`) and the paste-code login-provider set from the `@oh-my-pi/pi-ai` provider registry, removing the duplicated `CALLBACK_SERVER_PROVIDERS` tables in the model selector and the setup-wizard sign-in scene. + +### Fixed + +- Fixed package subpath exports for status-line, setup-wizard, tool-discovery, and gallery fixture modules so rewritten test imports resolve through `@oh-my-pi/pi-coding-agent`. +- Fixed runtime model provider discovery so extension-registered providers are now refreshed after extension load and extension-supplied models appear without restarting +- Fixed task-row shimmer timing so every running description starts its highlight on the first character together and reaches the last character together, regardless of text length. +- Fixed the `eval` tool's `read`/`write`/`append` helpers (both Python and JS backends) treating `local://` (and other internal-URL) paths as plain filesystem paths. `pathlib.Path`/`path.resolve` collapse `local://x.md` to `local:/x.md`, so `write("local://x.md", …)` created a junk `local:` directory under the cwd instead of writing where `read local://x.md` resolves. The helpers now substitute injected on-disk roots for known schemes (currently `local://`, pinned to the session's own `local://` root), reject path traversal and unknown `scheme://` paths, and leave plain paths resolving against the cwd. +- Fixed read and edit previews to surface the enclosing syntactic block's off-window boundary line (behind an ellipsis) when a shown line opens or closes a block whose other end falls outside the displayed range. Powered by a new tree-sitter `enclosingBlockBoundaries` native, so it covers brace languages and indentation languages (Python) using real syntactic spans, with a lexical bracket scan as fallback for unparseable sources. +- Fixed `tab.screenshot({ selector })` hanging for the entire cell budget on continuously-animating pages (WebGL / `backdrop-filter` "glass" effects). The element-screenshot path no longer routes through puppeteer's `scrollIntoViewIfNeeded()`, whose `IntersectionObserver` promise can stall indefinitely under heavy rendering; it now does a single instant `scrollIntoView` and captures with `scrollIntoView: false` (relying on `captureBeyondViewport`), so off-screen elements are still captured without the stall. +- Fixed follow-up message submissions to forward pending clipboard-pasted images to `session.prompt` in both streaming and non-streaming flows +- Fixed follow-up handling to clear consumed clipboard image state after submission so pasted images are not silently carried into later messages +- Fixed clipboard-pasted images being rejected when steering or following up during compaction. Instead of bailing with "Retry after it completes to send images", the message and its images are now queued via `queueCompactionMessage` and forwarded to the session (steer/follow-up/prompt) when the compaction queue flushes. +- Fixed edit tool result previews to show only current-file lines and collapse long inserted blocks instead of echoing removed content. +- Fixed `generateDiffString` to omit the mid-skip `...` placeholder between two nearby edits, conveying the elided gap via the jump in line numbers instead (consistent with how leading/trailing context skips already render). The placeholder row was indistinguishable from a genuine `...` context line and wasted a row in compact previews. +- Fixed concurrent interactive dialogs clobbering each other on the shared editor surface. `ExtensionUiController` presents the selector / input / editor modals by swapping a component into the single `editorContainer` and stealing focus, with no serialization — so a second `select`/`input`/`editor` request (from a hook, extension, the `ask` tool, or an internal flow) opened while one was already up would clear the container and re-focus, orphaning the first dialog. Its promise then hung until the caller's signal aborted (surfacing a stray `Ask input was cancelled` on top of the answered call). These modals are now serialized through `#presentDialog`: at most one shows at a time and the rest queue (FIFO); a queued request whose signal aborts before its turn resolves `undefined` and is never shown. The first dialog is still presented synchronously, so single-dialog timing is unchanged. +- Fixed the `ask` tool potentially hanging when the model emitted two `ask` calls in one tool batch. `ask` now declares `concurrency: "exclusive"`, so the agent loop serializes the batch and each question's selector runs to completion before the next starts, instead of racing for the shared selector surface. +- Expanded `@path/to/file` import references in CLAUDE.md / AGENTS.md / GEMINI.md (and the other discovered context-file flavors) when loading them into the system prompt, matching the convention used by Claude Code, Goose, and other agents. Imports resolve relative to the importing file's directory, support `~/...`, recurse up to 5 hops, and are skipped inside fenced code blocks and inline code spans so technical examples like `npm install @types/node` survive intact ([#2111](https://github.com/can1357/oh-my-pi/issues/2111)). + +### Removed + +- Removed the special Anthropic `claude-opus-4-8` tool-call batch cap; sessions no longer abort an in-flight provider stream after a fixed number of completed tool calls. + ## [15.10.4] - 2026-06-08 ### Added diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 2711909fc..c5c2d16f7 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": "15.10.4", + "version": "15.10.5", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "homepage": "https://omp.sh", "author": "Can Boluk", @@ -133,6 +133,14 @@ "types": "./src/cli/*.ts", "import": "./src/cli/*.ts" }, + "./cli/gallery-fixtures": { + "types": "./src/cli/gallery-fixtures/index.ts", + "import": "./src/cli/gallery-fixtures/index.ts" + }, + "./cli/gallery-fixtures/*": { + "types": "./src/cli/gallery-fixtures/*.ts", + "import": "./src/cli/gallery-fixtures/*.ts" + }, "./cli/commands/*": { "types": "./src/cli/commands/*.ts", "import": "./src/cli/commands/*.ts" @@ -441,6 +449,14 @@ "types": "./src/modes/controllers/*.ts", "import": "./src/modes/controllers/*.ts" }, + "./modes/setup-wizard": { + "types": "./src/modes/setup-wizard/index.ts", + "import": "./src/modes/setup-wizard/index.ts" + }, + "./modes/setup-wizard/*": { + "types": "./src/modes/setup-wizard/*.ts", + "import": "./src/modes/setup-wizard/*.ts" + }, "./modes/rpc/*": { "types": "./src/modes/rpc/*.ts", "import": "./src/modes/rpc/*.ts" @@ -498,6 +514,10 @@ "types": "./src/task/*.ts", "import": "./src/task/*.ts" }, + "./tool-discovery/*": { + "types": "./src/tool-discovery/*.ts", + "import": "./src/tool-discovery/*.ts" + }, "./tools": { "types": "./src/tools/index.ts", "import": "./src/tools/index.ts" diff --git a/packages/coding-agent/src/capability/rule-buckets.ts b/packages/coding-agent/src/capability/rule-buckets.ts index 16afdc307..0ffd99dfe 100644 --- a/packages/coding-agent/src/capability/rule-buckets.ts +++ b/packages/coding-agent/src/capability/rule-buckets.ts @@ -6,7 +6,7 @@ * manager, and splits the rest into the always-apply and rulebook buckets. * * Bucket precedence (matches docs/rulebook-matching-pipeline.md §5): - * 1. TTSR — non-empty `condition` that `TtsrManager.addRule` accepts + * 1. TTSR — non-empty `condition`/`astCondition` that `TtsrManager.addRule` accepts * 2. always — `alwaysApply === true` * 3. rulebook — has a `description` */ @@ -49,7 +49,9 @@ export function bucketRules( if (disabled.has(rule.name)) continue; if (!includeBuiltin && rule._source?.provider === BUILTIN_DEFAULTS_PROVIDER_ID) continue; - const isTtsrRule = rule.condition && rule.condition.length > 0 ? ttsrManager.addRule(rule) : false; + const hasTtsrCondition = + (rule.condition && rule.condition.length > 0) || (rule.astCondition && rule.astCondition.length > 0); + const isTtsrRule = hasTtsrCondition ? ttsrManager.addRule(rule) : false; if (isTtsrRule) continue; if (rule.alwaysApply === true) { alwaysApplyRules.push(rule); diff --git a/packages/coding-agent/src/capability/rule.ts b/packages/coding-agent/src/capability/rule.ts index 0b5d8d2b3..cdce087d3 100644 --- a/packages/coding-agent/src/capability/rule.ts +++ b/packages/coding-agent/src/capability/rule.ts @@ -26,6 +26,8 @@ export interface RuleFrontmatter { alwaysApply?: boolean; /** New key for TTSR match conditions. */ condition?: string | string[]; + /** TTSR match condition(s) expressed as ast-grep patterns (edit/write streams only). */ + astCondition?: string | string[]; /** New key for TTSR stream scope. */ scope?: string | string[]; /** Per-rule TTSR interrupt mode override. */ @@ -51,6 +53,8 @@ export interface Rule { description?: string; /** Regex condition(s) that can trigger TTSR interruption. */ condition?: string[]; + /** ast-grep pattern condition(s) that can trigger TTSR interruption (edit/write streams only). */ + astCondition?: string[]; /** Optional stream scope tokens (for example: text, thinking, tool:edit(*.ts)). */ scope?: string[]; /** Per-rule TTSR interrupt mode override (falls back to global ttsr.interruptMode). */ @@ -188,10 +192,14 @@ function isLikelyFileGlob(value: string): boolean { * - legacy `ttsr_trigger` / `ttsrTrigger` are accepted as a `condition` fallback * - condition tokens that look like file globs become scope shorthands: * `*.rs` => `tool:edit(*.rs)`, `tool:write(*.rs)` and a catch-all condition `.*` + * - `astCondition` holds ast-grep patterns and is kept verbatim (no glob inference) */ -export function parseRuleConditionAndScope(frontmatter: RuleFrontmatter): Pick { +export function parseRuleConditionAndScope( + frontmatter: RuleFrontmatter, +): Pick { const rawCondition = frontmatter.condition ?? frontmatter.ttsr_trigger ?? frontmatter.ttsrTrigger; const parsedCondition = normalizeRuleField(rawCondition); + const astCondition = normalizeRuleField(frontmatter.astCondition); const parsedScope = normalizeScopeField(frontmatter.scope); const inferredScope: string[] = []; @@ -213,6 +221,7 @@ export function parseRuleConditionAndScope(frontmatter: RuleFrontmatter): Pick 0 ? Array.from(new Set(condition)) : undefined, + astCondition, scope: scope.length > 0 ? Array.from(new Set(scope)) : undefined, }; } diff --git a/packages/coding-agent/src/cli/auth-broker-cli.ts b/packages/coding-agent/src/cli/auth-broker-cli.ts index 1c6f268f4..5a58bcb5c 100644 --- a/packages/coding-agent/src/cli/auth-broker-cli.ts +++ b/packages/coding-agent/src/cli/auth-broker-cli.ts @@ -30,6 +30,7 @@ import { type OAuthCredential, type OAuthProvider, type OAuthProviderInfo, + PROVIDER_REGISTRY, SqliteAuthCredentialStore, startAuthBroker, } from "@oh-my-pi/pi-ai"; @@ -75,13 +76,11 @@ const ACTIONS: readonly AuthBrokerAction[] = [ ]; /** Callback ports baked from the per-provider OAuth flow modules. */ -const CALLBACK_PORTS: Record = { - anthropic: 54545, - "openai-codex": 1455, - "google-gemini-cli": 8085, - "google-antigravity": 51121, - "gitlab-duo": 8080, -}; +const CALLBACK_PORTS: Record = Object.fromEntries( + PROVIDER_REGISTRY.flatMap(provider => + provider.callbackPort != null ? [[provider.id, provider.callbackPort] as [string, number]] : [], + ), +); function getTokenFilePath(): string { return path.join(getConfigRootDir(), "auth-broker.token"); diff --git a/packages/coding-agent/src/cli/auth-gateway-cli.ts b/packages/coding-agent/src/cli/auth-gateway-cli.ts index 1024914fd..fa3317333 100644 --- a/packages/coding-agent/src/cli/auth-gateway-cli.ts +++ b/packages/coding-agent/src/cli/auth-gateway-cli.ts @@ -357,7 +357,7 @@ export async function runAuthGatewayCommand(cmd: AuthGatewayCommandArgs): Promis /** * Providers whose chat endpoint expects a JSON-serialized credential blob * (`{ token, projectId, refreshToken, expiresAt, … }`) rather than the raw - * access token. Mirrors `getOAuthApiKey` in `packages/ai/src/utils/oauth`. + * access token. Mirrors `getOAuthApiKey` in `packages/ai/src/registry/oauth`. */ const STRUCTURED_API_KEY_PROVIDERS: ReadonlySet = new Set([ "github-copilot", diff --git a/packages/coding-agent/src/cli/list-models.ts b/packages/coding-agent/src/cli/list-models.ts index eab9320b8..70673350e 100644 --- a/packages/coding-agent/src/cli/list-models.ts +++ b/packages/coding-agent/src/cli/list-models.ts @@ -189,6 +189,11 @@ export async function runListModelsCommand(options: RunListModelsOptions): Promi modelRegistry.registerProvider(name, config, sourceId); } extensionsResult.runtime.pendingProviderRegistrations = []; + // Discover runtime (extension) provider catalogs now that they are registered. + // The full refresh in main.ts ran before extensions loaded, so this is the only + // point where extension-contributed dynamic providers get discovered. Cache-aware + // so it reuses the shared 24 h model cache instead of refetching every invocation. + await modelRegistry.refreshRuntimeProviders("online-if-uncached"); await listModels(modelRegistry, searchPattern); } diff --git a/packages/coding-agent/src/cli/update-cli.ts b/packages/coding-agent/src/cli/update-cli.ts index 3dddd8317..1d70e3255 100644 --- a/packages/coding-agent/src/cli/update-cli.ts +++ b/packages/coding-agent/src/cli/update-cli.ts @@ -2,9 +2,10 @@ * Update CLI command handler. * * Handles `omp update` to check for and install updates. - * Uses bun if available, otherwise downloads binary from GitHub releases. + * Uses the installer that owns the active omp executable when it can be detected. */ import * as fs from "node:fs"; +import * as os from "node:os"; import * as path from "node:path"; import { pipeline } from "node:stream/promises"; import { $which, APP_NAME, isEnoent, VERSION } from "@oh-my-pi/pi-utils"; @@ -14,6 +15,8 @@ import { theme } from "../modes/theme/theme"; const REPO = "can1357/oh-my-pi"; const PACKAGE = "@oh-my-pi/pi-coding-agent"; +const HOMEBREW_FORMULA = "can1357/tap/omp"; +const MISE_TOOL = "github:can1357/oh-my-pi"; /** * Official npm registry origin. * @@ -102,6 +105,46 @@ async function getBunGlobalBinDir(): Promise { } } +async function getHomebrewFormulaPrefix(): Promise { + if (!$which("brew")) return undefined; + for (const formula of [HOMEBREW_FORMULA, APP_NAME]) { + try { + const result = await $`brew --prefix ${formula}`.quiet().nothrow(); + if (result.exitCode !== 0) continue; + const output = result.text().trim(); + if (output.length > 0) return output; + } catch {} + } + return undefined; +} + +async function getMiseBinDirs(): Promise { + if (!$which("mise")) return []; + try { + const result = await $`mise bin-paths ${MISE_TOOL}`.quiet().nothrow(); + if (result.exitCode !== 0) return []; + return result + .text() + .split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line.length > 0); + } catch { + return []; + } +} + +function getMiseDataDir(): string { + const override = process.env.MISE_DATA_DIR; + if (override && override.length > 0) return override; + if (process.platform === "win32") { + const localAppData = process.env.LOCALAPPDATA; + if (localAppData && localAppData.length > 0) return path.join(localAppData, "mise"); + } + const xdgDataHome = process.env.XDG_DATA_HOME; + if (xdgDataHome && xdgDataHome.length > 0) return path.join(xdgDataHome, "mise"); + return path.join(os.homedir(), ".local", "share", "mise"); +} + function normalizePathForComparison(filePath: string): string { const normalized = path.normalize(filePath); if (process.platform === "win32") return normalized.toLowerCase(); @@ -129,34 +172,61 @@ function isPathInDirectory(filePath: string, directoryPath: string): boolean { // is a junction when Bun is installed via Scoop, so `bun pm bin -g` and the // PATH-resolved omp path can refer to the same directory through different // strings. path.resolve does not traverse junctions/symlinks; realpath does. - // Resolve the file's parent directory to tolerate the file itself not yet - // existing (e.g. a fresh install path) while still catching link-traversed - // equality once the directory exists. - const fileDir = tryRealpath(path.dirname(path.resolve(filePath))); + // Resolve both the file and its parent directory: the file catches manager + // links like Homebrew's `bin/omp -> Cellar/.../bin/omp`; the parent fallback + // still tolerates fresh install paths where the file does not exist yet. const dirReal = tryRealpath(path.resolve(directoryPath)); - if (!fileDir || !dirReal) return false; + if (!dirReal) return false; + const fileReal = tryRealpath(path.resolve(filePath)); + if (fileReal && isPathInDirectoryLexical(fileReal, dirReal)) return true; + const fileDir = tryRealpath(path.dirname(path.resolve(filePath))); + if (!fileDir) return false; const resolvedFile = path.join(fileDir, path.basename(filePath)); return isPathInDirectoryLexical(resolvedFile, dirReal); } -type UpdateTarget = { method: "bun" } | { method: "binary"; path: string }; +type UpdateMethod = "brew" | "mise" | "bun" | "binary"; -function resolveUpdateMethod(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" { - if (!bunBinDir) return "binary"; - return isPathInDirectory(ompPath, bunBinDir) ? "bun" : "binary"; +interface UpdateMethodResolutionOptions { + homebrewPrefix?: string; + miseBinDirs?: readonly string[]; + miseDataDir?: string; } -export function resolveUpdateMethodForTest(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" { - return resolveUpdateMethod(ompPath, bunBinDir); +type UpdateTarget = { method: "brew" } | { method: "mise" } | { method: "bun" } | { method: "binary"; path: string }; + +function resolveUpdateMethod( + ompPath: string, + bunBinDir: string | undefined, + options: UpdateMethodResolutionOptions = {}, +): UpdateMethod { + const { homebrewPrefix, miseBinDirs = [], miseDataDir } = options; + if (homebrewPrefix && isPathInDirectory(ompPath, path.join(homebrewPrefix, "bin"))) return "brew"; + if (miseBinDirs.some(dir => isPathInDirectory(ompPath, dir))) return "mise"; + if (miseDataDir && isPathInDirectory(ompPath, path.join(miseDataDir, "shims"))) return "mise"; + if (bunBinDir && isPathInDirectory(ompPath, bunBinDir)) return "bun"; + return "binary"; +} + +export function resolveUpdateMethodForTest( + ompPath: string, + bunBinDir: string | undefined, + options: UpdateMethodResolutionOptions = {}, +): UpdateMethod { + return resolveUpdateMethod(ompPath, bunBinDir, options); } async function resolveUpdateTarget(): Promise { const bunBinDir = await getBunGlobalBinDir(); + const homebrewPrefix = await getHomebrewFormulaPrefix(); + const miseAvailable = $which("mise") !== undefined; + const miseBinDirs = miseAvailable ? await getMiseBinDirs() : []; + const miseDataDir = miseAvailable ? getMiseDataDir() : undefined; const ompPath = resolveOmpPath(); if (ompPath) { - const method = resolveUpdateMethod(ompPath, bunBinDir); - if (method === "bun") return { method }; - return { method, path: ompPath }; + const method = resolveUpdateMethod(ompPath, bunBinDir, { homebrewPrefix, miseBinDirs, miseDataDir }); + if (method === "binary") return { method, path: ompPath }; + return { method }; } if (bunBinDir) return { method: "bun" }; @@ -376,6 +446,18 @@ export function buildBunInstallArgs(expectedVersion: string, nativeTag: string = return args; } +export function buildHomebrewUpdateArgs(force: boolean): string[] { + return [force ? "reinstall" : "upgrade", HOMEBREW_FORMULA]; +} + +export function buildMiseUpgradeArgs(): string[] { + return ["upgrade", MISE_TOOL, "--bump"]; +} + +export function buildMiseForceInstallArgs(expectedVersion: string): string[] { + return ["install", "--force", `${MISE_TOOL}@${expectedVersion}`]; +} + /** * Update via bun package manager. */ @@ -390,6 +472,42 @@ async function updateViaBun(expectedVersion: string): Promise { await printVerification(expectedVersion); } +async function updateViaHomebrew(expectedVersion: string, force: boolean): Promise { + console.log(chalk.dim("Updating Homebrew formulae...")); + const update = await $`brew update`.nothrow(); + if (update.exitCode !== 0) { + throw new Error(`brew update failed with exit code ${update.exitCode}`); + } + + console.log(chalk.dim("Updating via Homebrew...")); + const args = buildHomebrewUpdateArgs(force); + const result = await $`brew ${args}`.nothrow(); + if (result.exitCode !== 0) { + throw new Error(`brew ${args[0]} failed with exit code ${result.exitCode}`); + } + + await printVerification(expectedVersion); +} + +async function updateViaMise(expectedVersion: string, force: boolean): Promise { + console.log(chalk.dim("Updating via mise...")); + const args = buildMiseUpgradeArgs(); + const result = await $`mise ${args}`.nothrow(); + if (result.exitCode !== 0) { + throw new Error(`mise upgrade failed with exit code ${result.exitCode}`); + } + + if (force) { + const forceArgs = buildMiseForceInstallArgs(expectedVersion); + const forceResult = await $`mise ${forceArgs}`.nothrow(); + if (forceResult.exitCode !== 0) { + throw new Error(`mise install --force failed with exit code ${forceResult.exitCode}`); + } + } + + await printVerification(expectedVersion); +} + /** * Download a release binary to a target path, replacing an existing file. */ @@ -457,7 +575,11 @@ export async function runUpdateCommand(opts: { force: boolean; check: boolean }) // Choose update method based on the prioritized omp binary in PATH try { const target = await resolveUpdateTarget(); - if (target.method === "bun") { + if (target.method === "brew") { + await updateViaHomebrew(release.version, opts.force); + } else if (target.method === "mise") { + await updateViaMise(release.version, opts.force); + } else if (target.method === "bun") { await updateViaBun(release.version); } else { await updateViaBinaryAt(target.path, release.version); diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 7006b17d7..4d07a4d96 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -96,8 +96,8 @@ const STARTUP_MODEL_CACHE_PROVIDER_IDS: readonly string[] = [ ]; import type { ApiKeyResolver } from "@oh-my-pi/pi-ai"; -import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth"; -import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/utils/oauth/types"; +import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; +import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/oauth/types"; import { isRecord, logger } from "@oh-my-pi/pi-utils"; import { parseModelString, resolveProviderModelReference } from "../config/model-resolver"; import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme"; @@ -922,6 +922,9 @@ export class ModelRegistry { #runtimeProviderOverrides: Map = new Map(); #runtimeProvidersBySource: Map> = new Map(); #runtimeProviderSourceByName: Map = new Map(); + // Runtime model managers registered by extensions via fetchDynamicModels. + // Keyed by provider name; use the same SQLite cache path as builtins. + #runtimeModelManagers: Map; sourceId: string }> = new Map(); #rebuildPending: boolean = false; #rebuildSuspended: number = 0; @@ -999,6 +1002,27 @@ export class ModelRegistry { } } + /** + * Discover models for providers registered at runtime via `fetchDynamicModels` + * (extension providers). Merges the discovered catalog into the existing model + * set without reloading static models, so dynamically-discovered models from + * other providers are preserved. No-op when no runtime providers are registered. + * + * Drives the same SQLite model cache as built-in providers, so the default + * `online-if-uncached` strategy fetches at most once per cache TTL (24 h). + */ + async refreshRuntimeProviders(strategy: ModelRefreshStrategy = "online-if-uncached"): Promise { + if (this.#runtimeModelManagers.size === 0) { + return; + } + this.#suspendRebuild(); + try { + await this.#refreshRuntimeDiscoveries(strategy, new Set(this.#runtimeModelManagers.keys())); + } finally { + this.#resumeRebuild(); + } + } + #reloadStaticModels(): void { const currentMtime = this.#modelsConfigFile.getMtimeMs(); if (currentMtime !== null && currentMtime === this.#lastStaticLoadMtime) { @@ -1665,6 +1689,10 @@ export class ModelRegistry { } options.push(descriptor.createOptions(key)); } + // Append runtime model managers registered by extensions via fetchDynamicModels. + for (const { options: managerOpts } of this.#runtimeModelManagers.values()) { + options.push(managerOpts); + } return options; } @@ -2396,6 +2424,7 @@ export class ModelRegistry { this.#runtimeProviderApiKeys.delete(providerName); this.#runtimeProviderOverrides.delete(providerName); this.#runtimeModelOverlays = this.#runtimeModelOverlays.filter(overlay => overlay.provider !== providerName); + this.#runtimeModelManagers.delete(providerName); this.authStorage.removeConfigApiKey(providerName); } @@ -2559,6 +2588,47 @@ export class ModelRegistry { return; } + if (config.fetchDynamicModels) { + const fetcher = config.fetchDynamicModels; + const providerBaseUrl = config.baseUrl ?? ""; + const providerApi = config.api; + const providerHeaders = config.headers; + const providerApiKey = config.apiKey; + const providerAuthHeader = config.authHeader; + const providerCompat = config.compat; + const managerOptions: ModelManagerOptions = { + providerId: providerName as Parameters[0]["providerId"], + staticModels: [], + cacheDbPath: this.#cacheDbPath, + cacheTtlMs: 24 * 60 * 60 * 1000, + dynamicModelsAuthoritative: true, + fetchDynamicModels: async () => { + const apiKey = await this.authStorage.peekApiKey(providerName); + const resolvedKey = isAuthenticated(apiKey) ? apiKey : undefined; + const modelDefs = await fetcher(resolvedKey); + const results: Model[] = []; + for (const modelDef of modelDefs) { + const overlay = buildCustomModelOverlay( + providerName, + modelDef.baseUrl ?? providerBaseUrl, + modelDef.api ?? providerApi, + providerHeaders, + providerApiKey, + providerAuthHeader, + providerCompat, + undefined, + modelDef as CustomModelDefinitionLike, + ); + if (overlay) results.push(finalizeCustomModel(overlay, { useDefaults: true })); + } + return results; + }, + }; + this.#runtimeModelManagers.set(providerName, { options: managerOptions, sourceId: sourceId ?? "" }); + // Discovery is driven by refreshRuntimeProviders() after the drain — not + // here, so registration has no network side effect and callers can await. + } + if ( config.baseUrl || config.headers || @@ -2636,6 +2706,15 @@ export interface ProviderConfigInput { getApiKey?(credentials: OAuthCredentials): string; modifyModels?(models: Model[], credentials: OAuthCredentials): Model[]; }; + /** + * Async factory that fetches the live model list from the provider endpoint. + * When present, the result is run through the same SQLite model-cache as + * built-in providers (keyed by provider name, default 24 h TTL). + * The factory receives the resolved API key (undefined when unauthenticated). + */ + fetchDynamicModels?: ( + apiKey: string | undefined, + ) => Promise[number][]>; models?: Array<{ id: string; name: string; diff --git a/packages/coding-agent/src/debug/index.ts b/packages/coding-agent/src/debug/index.ts index 5def149a0..83c4c5964 100644 --- a/packages/coding-agent/src/debug/index.ts +++ b/packages/coding-agent/src/debug/index.ts @@ -204,7 +204,7 @@ export class DebugSelectorComponent extends Container { this.ctx.statusContainer.clear(); const block = new TranscriptBlock(); - block.addChild(new Text(theme.fg("success", `${theme.status.success} Performance report saved`), 1, 0)); + block.addChild(new Text(theme.fg("success", `+ Performance report saved`), 1, 0)); block.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0)); block.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0)); this.ctx.present(block); @@ -261,7 +261,7 @@ export class DebugSelectorComponent extends Container { this.ctx.statusContainer.clear(); const block = new TranscriptBlock(); - block.addChild(new Text(theme.fg("success", `${theme.status.success} Report bundle saved`), 1, 0)); + block.addChild(new Text(theme.fg("success", `+ Report bundle saved`), 1, 0)); block.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0)); block.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0)); this.ctx.present(block); @@ -298,7 +298,7 @@ export class DebugSelectorComponent extends Container { this.ctx.statusContainer.clear(); const block = new TranscriptBlock(); - block.addChild(new Text(theme.fg("success", `${theme.status.success} Memory report saved`), 1, 0)); + block.addChild(new Text(theme.fg("success", `+ Memory report saved`), 1, 0)); block.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0)); block.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0)); this.ctx.present(block); @@ -480,11 +480,7 @@ export class DebugSelectorComponent extends Container { this.ctx.present([ new Spacer(1), - new Text( - theme.fg("success", `${theme.status.success} Cleared ${result.removed} artifact directories`), - 1, - 0, - ), + new Text(theme.fg("success", `- Cleared ${result.removed} artifact directories`), 1, 0), ]); } catch (err) { loader.stop(); diff --git a/packages/coding-agent/src/discovery/at-imports.ts b/packages/coding-agent/src/discovery/at-imports.ts new file mode 100644 index 000000000..17ea63b3b --- /dev/null +++ b/packages/coding-agent/src/discovery/at-imports.ts @@ -0,0 +1,273 @@ +/** + * @-import expansion for context files (AGENTS.md / CLAUDE.md / GEMINI.md / …). + * + * Other coding agents (Claude Code, Goose, Cline, …) treat `@path/to/file` + * references inside their markdown memory files as inline includes. omp + * loads the same files in their native shape, so this module performs the + * same expansion before content lands in the system prompt. + * + * Semantics mirror Claude Code's documented behavior: + * - `@` must sit at start of line or after whitespace (so `git@github.com` + * and `user@example.com` are not treated as imports). + * - Relative paths resolve against the importing file's directory, not the + * working directory. + * - `~/...` resolves to the user's home directory. + * - Imports inside fenced code blocks (` ``` ` / `~~~`) and inline code + * spans (`` `…` ``) are preserved verbatim so technical examples like + * `npm install @types/node` survive intact. + * - Recursive imports are followed up to {@link MAX_AT_IMPORT_DEPTH} hops; + * cycles are broken silently. + * - When the referenced file cannot be read, the original `@token` is + * left untouched and a debug log is emitted. + * + * @see https://docs.claude.com/en/docs/claude-code/memory#import-additional-files + */ +import * as os from "node:os"; +import * as path from "node:path"; +import { logger } from "@oh-my-pi/pi-utils"; +import { readFile } from "../capability/fs"; + +/** Maximum number of recursive `@`-import hops. Matches Claude Code's documented cap. */ +export const MAX_AT_IMPORT_DEPTH = 5; + +/** + * Matches a candidate `@import` token: a leading boundary (start-of-string + * or single whitespace char) and a token whose first character is path-like. + * + * The boundary character is captured separately so the slice arithmetic in + * {@link expandLine} aligns with the `@` position, not the whitespace. + */ +const AT_IMPORT_REGEX = /(^|[ \t])@([./~A-Za-z0-9_-][^\s]*)/g; + +/** + * Trailing characters stripped from a captured path token: sentence-ending + * punctuation, closing brackets, quotes. A lone trailing period is treated + * as sentence grammar (e.g. `See @AGENTS.md.`) — legitimate file extensions + * still match because the stripped set is anchored at the very end of the + * token, so `@AGENTS.md` keeps the `.md` (the `d` is not in the set). + */ +const TRAILING_PUNCT = /[.,;:!?)\]}"']+$/; + +export interface ExpandAtImportsOptions { + /** Maximum hop depth (default: {@link MAX_AT_IMPORT_DEPTH}). */ + maxDepth?: number; + /** Override the home directory used to resolve `~/...` (default: `os.homedir()`). */ + home?: string; +} + +/** + * Expand `@path/to/file` references in `content` against `filePath`'s directory. + * + * Returns the expanded text. When no imports match, the original string is + * returned unchanged. + */ +export async function expandAtImports( + content: string, + filePath: string, + options: ExpandAtImportsOptions = {}, +): Promise { + const maxDepth = options.maxDepth ?? MAX_AT_IMPORT_DEPTH; + const home = options.home ?? os.homedir(); + const absoluteSource = path.resolve(filePath); + const visited = new Set([absoluteSource]); + return await expand(content, path.dirname(absoluteSource), 0, maxDepth, home, visited); +} + +async function expand( + content: string, + baseDir: string, + depth: number, + maxDepth: number, + home: string, + visited: Set, +): Promise { + if (depth >= maxDepth) return content; + + const segments = splitMarkdownSegments(content); + const out: string[] = []; + for (const segment of segments) { + if (segment.kind === "code") { + out.push(segment.text); + continue; + } + out.push(await expandTextSegment(segment.text, baseDir, depth, maxDepth, home, visited)); + } + return out.join(""); +} + +async function expandTextSegment( + text: string, + baseDir: string, + depth: number, + maxDepth: number, + home: string, + visited: Set, +): Promise { + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + lines[i] = await expandLine(lines[i], baseDir, depth, maxDepth, home, visited); + } + return lines.join("\n"); +} + +async function expandLine( + line: string, + baseDir: string, + depth: number, + maxDepth: number, + home: string, + visited: Set, +): Promise { + if (!line.includes("@")) return line; + + const matches: Array<{ start: number; end: number; importPath: string }> = []; + for (const m of line.matchAll(AT_IMPORT_REGEX)) { + const matchIndex = m.index ?? 0; + const leading = m[1]; + const rawToken = m[2]; + const atPos = matchIndex + leading.length; + if (isInsideInlineCode(line, atPos)) continue; + + const trimmedToken = rawToken.replace(TRAILING_PUNCT, ""); + if (trimmedToken.length === 0) continue; + + matches.push({ + start: atPos, + end: atPos + 1 + trimmedToken.length, + importPath: trimmedToken, + }); + } + + if (matches.length === 0) return line; + + const parts: string[] = []; + let cursor = 0; + for (const m of matches) { + parts.push(line.slice(cursor, m.start)); + const expanded = await resolveAndExpand(m.importPath, baseDir, depth, maxDepth, home, visited); + parts.push(expanded ?? line.slice(m.start, m.end)); + cursor = m.end; + } + parts.push(line.slice(cursor)); + return parts.join(""); +} + +async function resolveAndExpand( + importPath: string, + baseDir: string, + depth: number, + maxDepth: number, + home: string, + visited: Set, +): Promise { + const resolved = resolveImportPath(importPath, baseDir, home); + if (visited.has(resolved)) { + logger.debug("@-import: skipping cyclic include", { path: resolved }); + return null; + } + + const content = await readFile(resolved); + if (content === null) { + logger.debug("@-import: file not found", { path: resolved }); + return null; + } + + // Visited is shared across the whole expansion tree to break cycles, + // even cycles that span multiple importing files. + visited.add(resolved); + return await expand(content, path.dirname(resolved), depth + 1, maxDepth, home, visited); +} + +function resolveImportPath(importPath: string, baseDir: string, home: string): string { + if (importPath === "~") return path.resolve(home); + if (importPath.startsWith("~/")) return path.resolve(home, importPath.slice(2)); + if (path.isAbsolute(importPath)) return path.resolve(importPath); + return path.resolve(baseDir, importPath); +} + +interface MarkdownSegment { + kind: "text" | "code"; + text: string; +} + +/** + * Split markdown into alternating text/code segments by tracking fenced + * code blocks. Inline code spans are handled per-line by {@link isInsideInlineCode}. + * + * A fence is recognized as a line whose first non-whitespace run is three or + * more backticks (or tildes). The closing fence must use the same character + * with at least as many marks as the opener. + */ +function splitMarkdownSegments(content: string): MarkdownSegment[] { + const segments: MarkdownSegment[] = []; + const lines = content.split("\n"); + let buffer: string[] = []; + let bufferKind: MarkdownSegment["kind"] = "text"; + let fenceChar = ""; + let fenceLen = 0; + + const flush = (): void => { + if (buffer.length === 0) return; + segments.push({ kind: bufferKind, text: buffer.join("") }); + buffer = []; + }; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const isLast = i === lines.length - 1; + // Re-attach each line's trailing newline so adjacent segments + // concatenate without losing the boundary `\n`. + const lineText = isLast ? line : `${line}\n`; + const fence = matchFence(line); + + if (fence && bufferKind === "text") { + flush(); + bufferKind = "code"; + buffer.push(lineText); + fenceChar = fence.char; + fenceLen = fence.len; + } else if (fence && bufferKind === "code" && fence.char === fenceChar && fence.len >= fenceLen) { + buffer.push(lineText); + flush(); + bufferKind = "text"; + fenceChar = ""; + fenceLen = 0; + } else { + buffer.push(lineText); + } + + if (isLast) flush(); + } + return segments; +} + +function matchFence(line: string): { char: string; len: number } | null { + let i = 0; + while (i < line.length && (line[i] === " " || line[i] === "\t")) i++; + const char = line[i]; + if (char !== "`" && char !== "~") return null; + let len = 0; + while (i + len < line.length && line[i + len] === char) len++; + if (len < 3) return null; + return { char, len }; +} + +/** + * Returns `true` when `position` falls inside an unclosed inline-code span on + * this line. Implemented as a backtick-parity scan so it handles repeated + * delimiters like `` `` literal ` backtick `` `` correctly enough for the + * "@-imports inside `code` should not expand" case. + */ +function isInsideInlineCode(line: string, position: number): boolean { + let inSpan = false; + let i = 0; + while (i < position && i < line.length) { + if (line[i] === "`") { + while (i < line.length && line[i] === "`") i++; + inSpan = !inSpan; + } else { + i++; + } + } + return inSpan; +} diff --git a/packages/coding-agent/src/discovery/builtin-rules/index.ts b/packages/coding-agent/src/discovery/builtin-rules/index.ts index 3f0ce1c77..d8d545ffc 100644 --- a/packages/coding-agent/src/discovery/builtin-rules/index.ts +++ b/packages/coding-agent/src/discovery/builtin-rules/index.ts @@ -20,8 +20,10 @@ import tsNoAny from "./ts-no-any.md" with { type: "text" }; import tsNoDeprecatedLeftovers from "./ts-no-deprecated-leftovers.md" with { type: "text" }; import tsNoDynamicImport from "./ts-no-dynamic-import.md" with { type: "text" }; import tsNoReturnType from "./ts-no-return-type.md" with { type: "text" }; +import tsNoTestTimers from "./ts-no-test-timers.md" with { type: "text" }; import tsNoTinyFunctions from "./ts-no-tiny-functions.md" with { type: "text" }; import tsPromiseWithResolvers from "./ts-promise-with-resolvers.md" with { type: "text" }; +import tsRedundantClearGuard from "./ts-redundant-clear-guard.md" with { type: "text" }; import tsSetMap from "./ts-set-map.md" with { type: "text" }; /** A bundled rule's stable name and raw markdown (frontmatter + body). */ @@ -44,7 +46,9 @@ export const BUILTIN_RULE_SOURCES: readonly BuiltinRuleSource[] = [ { name: "ts-no-deprecated-leftovers", content: tsNoDeprecatedLeftovers }, { name: "ts-no-dynamic-import", content: tsNoDynamicImport }, { name: "ts-no-return-type", content: tsNoReturnType }, + { name: "ts-no-test-timers", content: tsNoTestTimers }, { name: "ts-no-tiny-functions", content: tsNoTinyFunctions }, { name: "ts-promise-with-resolvers", content: tsPromiseWithResolvers }, + { name: "ts-redundant-clear-guard", content: tsRedundantClearGuard }, { name: "ts-set-map", content: tsSetMap }, ]; diff --git a/packages/coding-agent/src/discovery/builtin-rules/ts-no-test-timers.md b/packages/coding-agent/src/discovery/builtin-rules/ts-no-test-timers.md new file mode 100644 index 000000000..121fec38a --- /dev/null +++ b/packages/coding-agent/src/discovery/builtin-rules/ts-no-test-timers.md @@ -0,0 +1,55 @@ +--- +description: Do not use real timers (Bun.sleep, setTimeout, setInterval) in tests — drive time with fake timers instead +condition: + - "Bun\\.sleep\\(" + - "\\bsetInterval\\(" + - "\\bsetTimeout\\(" +scope: "tool:edit(*.test.ts), tool:write(*.test.ts)" +interruptMode: never +--- + +**Do not reach for real wall-clock timers in test files.** `Bun.sleep(...)`, `setTimeout(...)`, and `setInterval(...)` tie a test's duration to real time: they slow the suite on every run, and any delay tuned to "long enough" eventually races on a loaded machine and flakes. + +## Why it's wrong + +- Real delays add fixed latency to every invocation; CI pays it on every run. +- A sleep sized to mask a race is a guess — the race resurfaces under load. +- A fixed wait hides *what* you are waiting for, so a failure points at a timeout instead of the real cause. + +## Avoid + +```typescript +test("debounce fires once", async () => { + const fn = debounce(handler, 100); + fn(); + await Bun.sleep(150); // real delay — slow and timing-dependent + expect(handler).toHaveBeenCalledTimes(1); +}); +``` + +## Use + +Drive time deterministically with fake timers: + +```typescript +import { expect, test, vi } from "bun:test"; + +test("debounce fires once", () => { + vi.useFakeTimers(); + const fn = debounce(handler, 100); + fn(); + vi.advanceTimersByTime(150); // advance the clock, no real wait + expect(handler).toHaveBeenCalledTimes(1); +}); +``` + +When the code under test resolves a promise or emits an event, await that signal directly instead of guessing a duration: + +```typescript +await once(emitter, "done"); // await the real event +const value = await pending; // await the promise the code already exposes +``` + +## Exceptions + +An integration test that deliberately exercises real timer behavior against the platform clock may need a genuine delay. Keep it rare, and add a short comment naming why deterministic time control will not work. diff --git a/packages/coding-agent/src/discovery/builtin-rules/ts-redundant-clear-guard.md b/packages/coding-agent/src/discovery/builtin-rules/ts-redundant-clear-guard.md new file mode 100644 index 000000000..4c4a7fec7 --- /dev/null +++ b/packages/coding-agent/src/discovery/builtin-rules/ts-redundant-clear-guard.md @@ -0,0 +1,75 @@ +--- +description: Do not guard clearTimeout/clearInterval/clearImmediate with a truthiness or null/undefined check — they accept null and undefined +scope: "tool:edit(*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}), tool:write(*.{ts,tsx,js,jsx,mts,cts,mjs,cjs})" +interruptMode: never +astCondition: + - "if ($X) clearTimeout($X)" + - "if ($X) { clearTimeout($X) }" + - "if ($X) clearInterval($X)" + - "if ($X) { clearInterval($X) }" + - "if ($X) clearImmediate($X)" + - "if ($X) { clearImmediate($X) }" + - "if ($X !== null) clearTimeout($X)" + - "if ($X !== null) { clearTimeout($X) }" + - "if ($X !== null) clearInterval($X)" + - "if ($X !== null) { clearInterval($X) }" + - "if ($X !== null) clearImmediate($X)" + - "if ($X !== null) { clearImmediate($X) }" + - "if ($X != null) clearTimeout($X)" + - "if ($X != null) { clearTimeout($X) }" + - "if ($X != null) clearInterval($X)" + - "if ($X != null) { clearInterval($X) }" + - "if ($X != null) clearImmediate($X)" + - "if ($X != null) { clearImmediate($X) }" + - "if ($X !== undefined) clearTimeout($X)" + - "if ($X !== undefined) { clearTimeout($X) }" + - "if ($X !== undefined) clearInterval($X)" + - "if ($X !== undefined) { clearInterval($X) }" + - "if ($X !== undefined) clearImmediate($X)" + - "if ($X !== undefined) { clearImmediate($X) }" + - "if ($X != undefined) clearTimeout($X)" + - "if ($X != undefined) { clearTimeout($X) }" + - "if ($X != undefined) clearInterval($X)" + - "if ($X != undefined) { clearInterval($X) }" + - "if ($X != undefined) clearImmediate($X)" + - "if ($X != undefined) { clearImmediate($X) }" +--- + +**Do not guard `clearTimeout` / `clearInterval` / `clearImmediate` with a truthiness or `null`/`undefined` check.** Per the WHATWG/Node timers spec these functions are no-ops when handed `null`, `undefined`, or any value that doesn't correspond to a live timer. The guard adds a redundant branch that the reader must still reason about. + +## Why it's wrong + +- The branch can never change behavior — clearing a missing/`null`/`undefined` handle does nothing. +- Extra branches inflate the code and hide the one line that matters. +- It signals a misunderstanding of the timer API to future readers. + +## Avoid + +```ts +if (this.timer) clearTimeout(this.timer); +if (handle !== null) clearInterval(handle); +if (id != undefined) { + clearImmediate(id); +} +``` + +## Use + +```ts +clearTimeout(this.timer); +clearInterval(handle); +clearImmediate(id); +``` + +## When a guard *is* warranted + +Keep the check only when the body does more than clear — e.g. it also reassigns the handle or runs other cleanup: + +```ts +if (this.timer) { + clearTimeout(this.timer); + this.timer = undefined; // extra work → guard is not purely redundant +} +``` + +This rule only fires when the clear call is the sole statement in the guarded branch, so those legitimate cases are left alone. diff --git a/packages/coding-agent/src/discovery/helpers.ts b/packages/coding-agent/src/discovery/helpers.ts index dc6eba68f..16dc1a866 100644 --- a/packages/coding-agent/src/discovery/helpers.ts +++ b/packages/coding-agent/src/discovery/helpers.ts @@ -169,7 +169,7 @@ export function buildRuleFromMarkdown( }, ): Rule { const { frontmatter, body } = parseFrontmatter(content, { source: filePath }); - const { condition, scope } = parseRuleConditionAndScope(frontmatter as RuleFrontmatter); + const { condition, astCondition, scope } = parseRuleConditionAndScope(frontmatter as RuleFrontmatter); let globs: string[] | undefined; if (Array.isArray(frontmatter.globs)) { @@ -192,6 +192,7 @@ export function buildRuleFromMarkdown( alwaysApply: frontmatter.alwaysApply === true, description: typeof frontmatter.description === "string" ? frontmatter.description : undefined, condition, + astCondition, scope, interruptMode, _source: source, diff --git a/packages/coding-agent/src/edit/diff.ts b/packages/coding-agent/src/edit/diff.ts index 143b73795..6759f5ae3 100644 --- a/packages/coding-agent/src/edit/diff.ts +++ b/packages/coding-agent/src/edit/diff.ts @@ -6,6 +6,7 @@ */ import * as Diff from "diff"; import { resolveToCwd } from "../tools/path-utils"; +import { type BlockContextSource, findBlockContextLines } from "../utils/block-context"; import { DEFAULT_FUZZY_THRESHOLD, EditMatchError, findMatch } from "./modes/replace"; import { adjustIndentation, normalizeToLF, stripBom } from "./normalize"; import { readEditFileText } from "./read-file"; @@ -54,11 +55,109 @@ function formatNumberedDiffLine(prefix: "+" | "-" | " ", lineNum: number, conten return `${prefix}${lineNum}|${content}`; } +type DiffSource = "old" | "new"; + +interface ParsedNumberedDiffRow { + prefix: "+" | "-" | " "; + lineNumber: number; + content: string; + source: DiffSource; +} + +function parseNumberedDiffRow(row: string): ParsedNumberedDiffRow | undefined { + const match = /^([+\- ])(\d+)\|(.*)$/s.exec(row); + if (!match) return undefined; + const prefix = match[1] as "+" | "-" | " "; + const lineNumber = Number.parseInt(match[2], 10); + if (!Number.isFinite(lineNumber)) return undefined; + return { + prefix, + lineNumber, + content: match[3] ?? "", + source: prefix === "+" ? "new" : "old", + }; +} + +function isDiffChangeRow(row: string | undefined): boolean { + return row !== undefined && (row.startsWith("+") || row.startsWith("-")); +} + +function adjustedContextInsertIndex(rows: readonly string[], index: number): number { + let start = index; + while (start > 0 && isDiffChangeRow(rows[start - 1])) start--; + let end = index; + while (end < rows.length && isDiffChangeRow(rows[end])) end++; + return index > start && index < end ? end : index; +} + +function insertBracketContextRows( + rows: string[], + source: DiffSource, + contextLines: ReadonlyMap, + seenRows: Set, +): void { + const context = [...contextLines].sort(([left], [right]) => left - right); + for (const [lineNumber, text] of context) { + const row = formatNumberedDiffLine(" ", lineNumber, text); + if (seenRows.has(row)) continue; + + let insertIndex = rows.length; + let previousSourceLine: number | undefined; + let nextSourceLine: number | undefined; + for (let i = 0; i < rows.length; i++) { + const parsed = parseNumberedDiffRow(rows[i]); + if (!parsed || parsed.source !== source) continue; + if (parsed.lineNumber < lineNumber) { + previousSourceLine = parsed.lineNumber; + continue; + } + nextSourceLine = parsed.lineNumber; + insertIndex = i; + break; + } + + const chunk: string[] = []; + if (previousSourceLine !== undefined && lineNumber > previousSourceLine + 1) chunk.push("..."); + chunk.push(row); + if (nextSourceLine !== undefined && nextSourceLine > lineNumber + 1) chunk.push("..."); + + const adjustedIndex = adjustedContextInsertIndex(rows, insertIndex); + rows.splice(adjustedIndex, 0, ...chunk); + for (const inserted of chunk) seenRows.add(inserted); + } +} + +function addMatchingBracketContextRows( + rows: string[], + oldLines: readonly string[], + newLines: readonly string[], + source: BlockContextSource, +): void { + const oldVisible: number[] = []; + const newVisible: number[] = []; + const seenRows = new Set(rows); + + for (const row of rows) { + const parsed = parseNumberedDiffRow(row); + if (!parsed) continue; + if (parsed.source === "old") oldVisible.push(parsed.lineNumber); + else newVisible.push(parsed.lineNumber); + } + + insertBracketContextRows(rows, "old", findBlockContextLines(oldLines, oldVisible, source), seenRows); + insertBracketContextRows(rows, "new", findBlockContextLines(newLines, newVisible, source), seenRows); +} + /** * Generate a unified diff string with line numbers and context. * Returns both the diff string and the first changed line number (in the new file). */ -export function generateDiffString(oldContent: string, newContent: string, contextLines = 2): DiffResult { +export function generateDiffString( + oldContent: string, + newContent: string, + contextLines = 2, + source: BlockContextSource = {}, +): DiffResult { const parts = Diff.diffLines(oldContent, newContent); const output: string[] = []; @@ -133,8 +232,10 @@ export function generateDiffString(oldContent: string, newContent: string, conte newLineNum++; } + // Mid-skip placeholder is omitted too: the jump between the trailing + // number of the leading context and the leading number of the + // trailing context conveys the gap, just like leading/trailing skips. if (middleSkip > 0) { - output.push(formatNumberedDiffLine(" ", oldLineNum, "...")); oldLineNum += middleSkip; newLineNum += middleSkip; for (const line of linesToShow.slice(firstChunkLength)) { @@ -160,6 +261,8 @@ export function generateDiffString(oldContent: string, newContent: string, conte } } + addMatchingBracketContextRows(output, oldContent.split("\n"), newContent.split("\n"), source); + return { diff: output.join("\n"), firstChangedLine }; } @@ -187,7 +290,12 @@ export interface ReplaceResult { * Generate a unified diff string without file headers. * Returns both the diff string and the first changed line number (in the new file). */ -export function generateUnifiedDiffString(oldContent: string, newContent: string, contextLines = 3): DiffResult { +export function generateUnifiedDiffString( + oldContent: string, + newContent: string, + contextLines = 3, + source: BlockContextSource = {}, +): DiffResult { const patch = Diff.structuredPatch("", "", oldContent, newContent, "", "", { context: contextLines }); const output: string[] = []; let firstChangedLine: number | undefined; @@ -218,6 +326,8 @@ export function generateUnifiedDiffString(oldContent: string, newContent: string } } + addMatchingBracketContextRows(output, oldContent.split("\n"), newContent.split("\n"), source); + return { diff: output.join("\n"), firstChangedLine }; } @@ -805,7 +915,7 @@ export async function computeEditDiff( }; } - return generateDiffString(normalizedContent, result.content); + return generateDiffString(normalizedContent, result.content, undefined, { path }); } catch (err) { return { error: err instanceof Error ? err.message : String(err) }; } diff --git a/packages/coding-agent/src/edit/hashline/diff.ts b/packages/coding-agent/src/edit/hashline/diff.ts index 534aa43ef..fe3fecdda 100644 --- a/packages/coding-agent/src/edit/hashline/diff.ts +++ b/packages/coding-agent/src/edit/hashline/diff.ts @@ -230,7 +230,7 @@ export async function computeHashlineSectionDiff( if (options.streaming) return buildStreamingSectionDiff(section, normalized); const result = applyPreviewEdits({ section, absolutePath, normalized, snapshots, options }); if (normalized === result.text) return { error: `No changes would be made to ${section.path}.` }; - return generateDiffString(normalized, result.text); + return generateDiffString(normalized, result.text, undefined, { path: section.path }); } catch (err) { return { error: err instanceof Error ? err.message : String(err) }; } diff --git a/packages/coding-agent/src/edit/hashline/execute.ts b/packages/coding-agent/src/edit/hashline/execute.ts index dffdd61c3..54d091c94 100644 --- a/packages/coding-agent/src/edit/hashline/execute.ts +++ b/packages/coding-agent/src/edit/hashline/execute.ts @@ -97,7 +97,7 @@ function renderSection(result: PatchSectionResult, diagnostics: FileDiagnosticsR }; } - const diff = generateDiffString(result.before, result.after); + const diff = generateDiffString(result.before, result.after, undefined, { path: result.path }); const preview = buildCompactDiffPreview(diff.diff); const meta = outputMeta() .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? []) diff --git a/packages/coding-agent/src/edit/modes/patch.ts b/packages/coding-agent/src/edit/modes/patch.ts index 96d8a6a24..2734734f1 100644 --- a/packages/coding-agent/src/edit/modes/patch.ts +++ b/packages/coding-agent/src/edit/modes/patch.ts @@ -1571,7 +1571,9 @@ export async function computePatchDiff( if (!normalizedOld && !normalizedNew) { return { diff: "", firstChangedLine: undefined }; } - return generateUnifiedDiffString(normalizedOld, normalizedNew); + return generateUnifiedDiffString(normalizedOld, normalizedNew, undefined, { + path: result.change.newPath ?? result.change.path, + }); } catch (err) { return { error: err instanceof Error ? err.message : String(err) }; } @@ -1785,7 +1787,9 @@ export async function executePatchSingle( if (result.change.type === "update" && result.change.oldContent && result.change.newContent) { const normalizedOld = normalizeToLF(stripBom(result.change.oldContent).text); const normalizedNew = normalizeToLF(stripBom(result.change.newContent).text); - diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew); + diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew, undefined, { + path: result.change.newPath ?? result.change.path, + }); } let resultText: string; diff --git a/packages/coding-agent/src/edit/modes/replace.ts b/packages/coding-agent/src/edit/modes/replace.ts index be3fde872..4784bd75d 100644 --- a/packages/coding-agent/src/edit/modes/replace.ts +++ b/packages/coding-agent/src/edit/modes/replace.ts @@ -1078,7 +1078,7 @@ export async function executeReplaceSingle( ); invalidateFsScanAfterWrite(absolutePath); - const diffResult = generateDiffString(normalizedContent, result.content); + const diffResult = generateDiffString(normalizedContent, result.content, undefined, { path }); const resultText = result.count > 1 ? `Successfully replaced ${result.count} occurrences in ${path}.` diff --git a/packages/coding-agent/src/edit/renderer.ts b/packages/coding-agent/src/edit/renderer.ts index edaed012e..275bbb612 100644 --- a/packages/coding-agent/src/edit/renderer.ts +++ b/packages/coding-agent/src/edit/renderer.ts @@ -260,6 +260,7 @@ function renderEditHeader( uiTheme: Theme, options: { icon: "pending" | "success" | "error"; + iconOverride?: string; spinnerFrame?: number; op?: Operation; rawPath: string; @@ -279,8 +280,16 @@ function renderEditHeader( const formatted = formatEditDescription(options.rawPath, uiTheme, descriptionOptions); const suffix = `${options.statsSuffix ?? ""}${options.extraSuffix ?? ""}`; const buildHeader = (description: string): string => - renderStatusLine({ icon: options.icon, spinnerFrame: options.spinnerFrame, title, description }, uiTheme) + - suffix; + renderStatusLine( + { + icon: options.icon, + iconOverride: options.iconOverride, + spinnerFrame: options.spinnerFrame, + title, + description, + }, + uiTheme, + ) + suffix; const header = buildHeader(formatted.description); const overflow = visibleWidth(header) - editHeaderLabelBudget(width, uiTheme); @@ -633,6 +642,7 @@ function renderSingleFileResult( const statsSuffix = headerDiff ? formatDiffStatsSuffix(headerDiff, uiTheme) : ""; const header = renderEditHeader(width, uiTheme, { icon: isError ? "error" : "success", + iconOverride: !isError && !options.isPartial ? uiTheme.styledSymbol("tool.edit", "accent") : undefined, op, rawPath, rename, diff --git a/packages/coding-agent/src/eval/__tests__/helpers-local-roots.test.ts b/packages/coding-agent/src/eval/__tests__/helpers-local-roots.test.ts new file mode 100644 index 000000000..445144bfa --- /dev/null +++ b/packages/coding-agent/src/eval/__tests__/helpers-local-roots.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "bun:test"; +import * as path from "node:path"; +import { TempDir } from "@oh-my-pi/pi-utils"; +import { createHelpers, type HelperContext } from "../js/shared/helpers"; + +/** + * The eval helpers (`read`/`write`/`append`) must substitute injected on-disk + * roots for internal-URL schemes. Without it, `write("local://x.md")` hits a + * stdlib `path.resolve` that collapses `local://` to `local:/`, creating a junk + * `local:` directory under the cwd instead of landing where `read local://x.md` + * resolves. These lock the substitution contract and its guards. + */ +function makeCtx(cwd: string, roots: Record): HelperContext { + return { + cwd: () => cwd, + env: new Map(), + localRoots: () => roots, + emitStatus: () => {}, + }; +} + +describe("eval js helpers internal-url resolution", () => { + it("writes, reads, and appends local:// under the injected root", async () => { + using tmp = TempDir.createSync("@eval-helpers-local-"); + const root = path.join(tmp.path(), "local"); + const helpers = createHelpers(makeCtx(tmp.path(), { local: root })); + + const written = await helpers.writeFile("local://notes/merge-map.md", "hello"); + expect(written).toBe(path.join(root, "notes", "merge-map.md")); + expect(await Bun.file(written).text()).toBe("hello"); + expect(await helpers.read("local://notes/merge-map.md")).toBe("hello"); + + await helpers.append("local://notes/merge-map.md", " world"); + expect(await helpers.read("local://notes/merge-map.md")).toBe("hello world"); + + // Regression: no literal `local:` directory created under the cwd. + expect(await Bun.file(path.join(tmp.path(), "local:")).exists()).toBe(false); + expect(await Bun.file(path.join(tmp.path(), "local:", "notes", "merge-map.md")).exists()).toBe(false); + }); + + it("rejects traversal and schemes without an injected root", async () => { + using tmp = TempDir.createSync("@eval-helpers-guard-"); + const helpers = createHelpers(makeCtx(tmp.path(), { local: path.join(tmp.path(), "local") })); + + await expect(helpers.writeFile("local://../escape.md", "x")).rejects.toThrow(/traversal|escapes/i); + await expect(helpers.writeFile("memory://x.md", "x")).rejects.toThrow(/not supported/i); + await expect(helpers.read("https://example.com/page")).rejects.toThrow(/not supported/i); + }); + + it("leaves plain relative and absolute paths resolving against the cwd", async () => { + using tmp = TempDir.createSync("@eval-helpers-plain-"); + const helpers = createHelpers(makeCtx(tmp.path(), {})); + + const rel = await helpers.writeFile("foo/bar.txt", "bar"); + expect(rel).toBe(path.join(tmp.path(), "foo", "bar.txt")); + expect(await helpers.read("foo/bar.txt")).toBe("bar"); + }); +}); diff --git a/packages/coding-agent/src/eval/backend.ts b/packages/coding-agent/src/eval/backend.ts index ed8a85f12..c1938940c 100644 --- a/packages/coding-agent/src/eval/backend.ts +++ b/packages/coding-agent/src/eval/backend.ts @@ -1,3 +1,4 @@ +import { buildEvalUrlRoots, type LocalProtocolOptions } from "../internal-urls"; import type { ToolSession } from "../tools"; import type { EvalDisplayOutput, EvalLanguage, EvalStatusEvent } from "./types"; @@ -56,3 +57,17 @@ export interface ExecutorBackend { /** Execute one cell. Caller invokes once per cell and aggregates results. */ execute(code: string, opts: ExecutorBackendExecOptions): Promise; } + +/** + * Resolve the on-disk roots that the eval helpers substitute for internal-URL + * schemes (currently `local://`). Prefers the session's own + * {@link LocalProtocolOptions} — the exact mapping `read local://…` uses — so an + * eval `write("local://x")` and a later `read local://x` agree on the location. + */ +export function resolveEvalUrlRoots(session: ToolSession): Record { + const options: LocalProtocolOptions = session.localProtocolOptions ?? { + getArtifactsDir: () => session.getArtifactsDir?.() ?? null, + getSessionId: () => session.getSessionId?.() ?? null, + }; + return buildEvalUrlRoots(options); +} diff --git a/packages/coding-agent/src/eval/js/context-manager.ts b/packages/coding-agent/src/eval/js/context-manager.ts index d0025021f..8a7679935 100644 --- a/packages/coding-agent/src/eval/js/context-manager.ts +++ b/packages/coding-agent/src/eval/js/context-manager.ts @@ -68,6 +68,7 @@ export async function executeInVmContext(options: { sessionId: string; cwd: string; session: ToolSession; + localRoots?: Record; reset?: boolean; code: string; filename: string; @@ -100,7 +101,7 @@ export async function executeInVmContext(options: { } const session = await acquireSession( options.sessionKey, - { cwd: options.cwd, sessionId: options.sessionId }, + { cwd: options.cwd, sessionId: options.sessionId, localRoots: options.localRoots }, options.timeoutMs, ); return await runOnce(session, options); @@ -132,6 +133,7 @@ async function runOnce( sessionId: string; cwd: string; session: ToolSession; + localRoots?: Record; code: string; filename: string; runState: VmRunState; @@ -171,7 +173,7 @@ async function runOnce( runId, code: options.code, filename: options.filename, - snapshot: { cwd: options.cwd, sessionId: options.sessionId }, + snapshot: { cwd: options.cwd, sessionId: options.sessionId, localRoots: options.localRoots }, }); return await promise; } finally { diff --git a/packages/coding-agent/src/eval/js/executor.ts b/packages/coding-agent/src/eval/js/executor.ts index 430b38348..ac227f98e 100644 --- a/packages/coding-agent/src/eval/js/executor.ts +++ b/packages/coding-agent/src/eval/js/executor.ts @@ -24,6 +24,8 @@ export interface JsExecutorOptions { artifactPath?: string; artifactId?: string; session: ToolSession; + /** On-disk roots the helpers substitute for internal-URL schemes (e.g. `local://`). */ + localRoots?: Record; } export interface JsResult { @@ -96,6 +98,7 @@ export async function executeJs(code: string, options: JsExecutorOptions): Promi sessionId: options.sessionId, cwd: options.cwd ?? options.session.cwd, session: options.session, + localRoots: options.localRoots, reset: options.reset, code, filename: `js-cell-${crypto.randomUUID()}.js`, diff --git a/packages/coding-agent/src/eval/js/index.ts b/packages/coding-agent/src/eval/js/index.ts index 9d2c7bf30..a107cf8d4 100644 --- a/packages/coding-agent/src/eval/js/index.ts +++ b/packages/coding-agent/src/eval/js/index.ts @@ -1,5 +1,10 @@ import type { ToolSession } from "../../tools"; -import type { ExecutorBackend, ExecutorBackendExecOptions, ExecutorBackendResult } from "../backend"; +import { + type ExecutorBackend, + type ExecutorBackendExecOptions, + type ExecutorBackendResult, + resolveEvalUrlRoots, +} from "../backend"; import { executeJs } from "./executor"; const JS_SESSION_PREFIX = "js:"; @@ -30,6 +35,7 @@ export default { onChunk: opts.onChunk, onStatus: opts.onStatus, session: opts.session, + localRoots: resolveEvalUrlRoots(opts.session), }); return { output: result.output, diff --git a/packages/coding-agent/src/eval/js/shared/helpers.ts b/packages/coding-agent/src/eval/js/shared/helpers.ts index aae45036f..0e8ac7aea 100644 --- a/packages/coding-agent/src/eval/js/shared/helpers.ts +++ b/packages/coding-agent/src/eval/js/shared/helpers.ts @@ -24,6 +24,12 @@ export interface HelperOptions { export interface HelperContext { cwd(): string; env: Map; + /** + * On-disk roots for internal-URL schemes the helpers accept (e.g. + * `{ local: "/…/artifacts/local" }`). A path like `local://x.md` is rewritten + * to `/x.md` before any filesystem op; unknown schemes are rejected. + */ + localRoots(): Record; emitStatus(event: JsStatusEvent): void; } @@ -66,7 +72,7 @@ export function createHelpers(ctx: HelperContext): HelperBundle { if (!isWriteData(data)) { throw new ToolError("write() expects string, Blob, ArrayBuffer, or TypedArray data"); } - const filePath = resolvePath(ctx, rawPath); + const filePath = resolveHelperPath(ctx, rawPath, "write"); if (typeof data === "string" || data instanceof Blob || data instanceof ArrayBuffer) { await Bun.write(filePath, data); } else { @@ -76,7 +82,7 @@ export function createHelpers(ctx: HelperContext): HelperBundle { return filePath; }, append: async (rawPath, content) => { - const target = resolvePath(ctx, rawPath); + const target = resolveHelperPath(ctx, rawPath, "write"); await Bun.write( target, `${await Bun.file(target) @@ -202,19 +208,60 @@ function getMergedEnv(ctx: HelperContext): Record { return merged; } +const INTERNAL_URL_RE = /^([a-z][a-z0-9+.-]*):\/\/(.*)$/i; + function resolvePath(ctx: HelperContext, value: string): string { if (path.isAbsolute(value)) return path.normalize(value); return path.resolve(ctx.cwd(), value); } +/** + * Map a raw helper path to an absolute filesystem path. Plain paths resolve + * against the cwd; an internal-URL whose scheme has an injected root (e.g. + * `local://`) is rewritten under that root; any other `scheme://` is rejected + * so we never silently create a literal `scheme:/` directory. + */ +function resolveHelperPath(ctx: HelperContext, rawPath: string, op: "read" | "write"): string { + const match = INTERNAL_URL_RE.exec(rawPath); + if (!match) return resolvePath(ctx, rawPath); + const scheme = match[1].toLowerCase(); + const root = ctx.localRoots()[scheme]; + if (!root) { + throw new ToolError(`Protocol paths are not supported by ${op}(): ${rawPath}`); + } + return resolveUnderRoot(scheme, root, match[2], rawPath); +} + +/** Resolve an internal-URL relative path under its root, mirroring the host + * local-protocol handler: decode, reject absolute/traversal, confine to root. */ +function resolveUnderRoot(scheme: string, root: string, rawRelative: string, rawPath: string): string { + let relative: string; + try { + relative = decodeURIComponent(rawRelative.replaceAll("\\", "/")); + } catch { + throw new ToolError(`Invalid URL encoding in ${scheme}:// path: ${rawPath}`); + } + const rootPath = path.resolve(root); + if (relative === "") return rootPath; + if (path.isAbsolute(relative)) { + throw new ToolError(`Absolute paths are not allowed in ${scheme}:// URLs: ${rawPath}`); + } + const normalized = path.normalize(relative); + if (normalized.startsWith("..") || normalized.includes("/../") || normalized.includes("/..")) { + throw new ToolError(`Path traversal (..) is not allowed in ${scheme}:// URLs: ${rawPath}`); + } + const resolved = path.resolve(rootPath, normalized); + if (resolved !== rootPath && !resolved.startsWith(`${rootPath}${path.sep}`)) { + throw new ToolError(`${scheme}:// path escapes its root: ${rawPath}`); + } + return resolved; +} + async function resolveRegularFile( ctx: HelperContext, rawPath: string, ): Promise<{ filePath: string; file: Bun.BunFile; size: number }> { - if (/^[a-z][a-z0-9+.-]*:\/\//i.test(rawPath)) { - throw new ToolError(`Protocol paths are not supported by read(): ${rawPath}`); - } - const filePath = resolvePath(ctx, rawPath); + const filePath = resolveHelperPath(ctx, rawPath, "read"); const file = Bun.file(filePath); const stat = await file.stat(); if (stat.isDirectory()) { diff --git a/packages/coding-agent/src/eval/js/shared/runtime.ts b/packages/coding-agent/src/eval/js/shared/runtime.ts index 51ccc21f2..fb5baa066 100644 --- a/packages/coding-agent/src/eval/js/shared/runtime.ts +++ b/packages/coding-agent/src/eval/js/shared/runtime.ts @@ -42,6 +42,11 @@ export interface RuntimeOptions { * via `setRunScope()` instead. */ extraGlobals?: Record; + /** + * On-disk roots the helpers substitute for internal-URL schemes (e.g. + * `{ local: "/…/artifacts/local" }`). Stable for the worker's lifetime. + */ + localRoots?: Record; } // Strict base64: characters from the standard alphabet plus optional `=` padding, and a @@ -126,15 +131,18 @@ export class JsRuntime { #env: Map; #als = new AsyncLocalStorage(); #moduleLoader: LocalModuleLoader; + #localRoots: Record; constructor(opts: RuntimeOptions) { this.#cwd = opts.initialCwd; this.sessionId = opts.sessionId; this.#env = new Map(); this.#moduleLoader = new LocalModuleLoader(this.sessionId); + this.#localRoots = opts.localRoots ?? {}; this.helpers = createHelpers({ cwd: () => this.#activeCwd(), env: this.#env, + localRoots: () => this.#localRoots, emitStatus: event => this.#activeHooks("emitStatus")?.onDisplay({ type: "status", event }), }); this.#install(opts.extraGlobals); diff --git a/packages/coding-agent/src/eval/js/worker-core.ts b/packages/coding-agent/src/eval/js/worker-core.ts index 552e9af9a..3be139b19 100644 --- a/packages/coding-agent/src/eval/js/worker-core.ts +++ b/packages/coding-agent/src/eval/js/worker-core.ts @@ -71,6 +71,7 @@ export class WorkerCore { this.#runtime = new JsRuntime({ initialCwd: snapshot.cwd, sessionId: snapshot.sessionId, + localRoots: snapshot.localRoots, }); return this.#runtime; } diff --git a/packages/coding-agent/src/eval/js/worker-protocol.ts b/packages/coding-agent/src/eval/js/worker-protocol.ts index 713793e10..118aae9ff 100644 --- a/packages/coding-agent/src/eval/js/worker-protocol.ts +++ b/packages/coding-agent/src/eval/js/worker-protocol.ts @@ -5,6 +5,12 @@ export type { JsDisplayOutput } from "./shared/types"; export interface SessionSnapshot { cwd: string; sessionId: string; + /** + * On-disk roots the helpers substitute for internal-URL schemes + * (e.g. `{ local: "/…/artifacts/local" }`). Lets `read`/`write`/`append` + * accept `local://…` paths instead of writing a literal `local:/` directory. + */ + localRoots?: Record; } export interface RunErrorPayload { diff --git a/packages/coding-agent/src/eval/py/executor.ts b/packages/coding-agent/src/eval/py/executor.ts index 37d1c1b05..c33a0b25c 100644 --- a/packages/coding-agent/src/eval/py/executor.ts +++ b/packages/coding-agent/src/eval/py/executor.ts @@ -56,6 +56,13 @@ export interface PythonExecutorOptions { /** Artifact path/id for full output storage */ artifactPath?: string; artifactId?: string; + /** + * On-disk roots the prelude helpers (`read`/`write`/`append`) substitute for + * internal-URL schemes (e.g. `{ local: "/…/artifacts/local" }`). Exported to + * the kernel as `PI_EVAL_LOCAL_ROOTS` (JSON) so `write("local://x")` lands + * where `read local://x` resolves instead of a literal `local:/` directory. + */ + localRoots?: Record; /** * ToolSession used to resolve host-side `tool.(args)` calls made from * the Python prelude's bridge proxy. When omitted, the bridge env vars are @@ -275,6 +282,7 @@ const MANAGED_KERNEL_ENV_KEYS = [ "PI_TOOL_BRIDGE_URL", "PI_TOOL_BRIDGE_TOKEN", "PI_TOOL_BRIDGE_SESSION", + "PI_EVAL_LOCAL_ROOTS", ] as const; function buildKernelEnvPatch(options: { @@ -282,13 +290,16 @@ function buildKernelEnvPatch(options: { artifactsDir?: string; bridgeSessionId?: string; bridge?: { url: string; token: string }; + localRoots?: Record; }): KernelRuntimeEnv { + const localRoots = options.localRoots; return { PI_SESSION_FILE: options.sessionFile ?? null, PI_ARTIFACTS_DIR: options.artifactsDir ?? null, PI_TOOL_BRIDGE_URL: options.bridge?.url ?? null, PI_TOOL_BRIDGE_TOKEN: options.bridge?.token ?? null, PI_TOOL_BRIDGE_SESSION: options.bridge && options.bridgeSessionId ? options.bridgeSessionId : null, + PI_EVAL_LOCAL_ROOTS: localRoots && Object.keys(localRoots).length > 0 ? JSON.stringify(localRoots) : null, }; } @@ -297,6 +308,7 @@ function buildKernelEnv(options: { artifactsDir?: string; bridgeSessionId?: string; bridge?: { url: string; token: string }; + localRoots?: Record; }): Record | undefined { const patch = buildKernelEnvPatch(options); const env: Record = {}; diff --git a/packages/coding-agent/src/eval/py/index.ts b/packages/coding-agent/src/eval/py/index.ts index 4eed80e98..fa6f4cc9b 100644 --- a/packages/coding-agent/src/eval/py/index.ts +++ b/packages/coding-agent/src/eval/py/index.ts @@ -1,5 +1,10 @@ import type { ToolSession } from "../../tools"; -import type { ExecutorBackend, ExecutorBackendExecOptions, ExecutorBackendResult } from "../backend"; +import { + type ExecutorBackend, + type ExecutorBackendExecOptions, + type ExecutorBackendResult, + resolveEvalUrlRoots, +} from "../backend"; import { executePython, type PythonExecutorOptions } from "./executor"; import { checkPythonKernelAvailability } from "./kernel"; @@ -34,6 +39,7 @@ export default { kernelMode, sessionFile: opts.sessionFile, artifactsDir: opts.session.getArtifactsDir?.() ?? undefined, + localRoots: resolveEvalUrlRoots(opts.session), kernelOwnerId: opts.kernelOwnerId, reset: opts.reset, artifactPath: opts.artifactPath, diff --git a/packages/coding-agent/src/eval/py/prelude.py b/packages/coding-agent/src/eval/py/prelude.py index 744ef453c..0eb2ec942 100644 --- a/packages/coding-agent/src/eval/py/prelude.py +++ b/packages/coding-agent/src/eval/py/prelude.py @@ -3,7 +3,8 @@ from __future__ import annotations if "__omp_prelude_loaded__" not in globals(): __omp_prelude_loaded__ = True from pathlib import Path - import os, json, math + import os, json, math, re + from urllib.parse import unquote # __omp_display is injected by runner.py before the prelude executes; it # mirrors IPython's display() semantics with the same MIME bundle output. @@ -53,9 +54,47 @@ if "__omp_prelude_loaded__" not in globals(): _emit_status("env", key=key, value=val, action="get") return val + _OMP_INTERNAL_URL_RE = re.compile(r"^([a-z][a-z0-9+.-]*)://(.*)$", re.IGNORECASE) + + def _resolve_omp_path(path: str | Path) -> Path: + """Map a helper path to a real filesystem Path. + + A `scheme://…` whose scheme has an injected on-disk root (e.g. + `local://`, via PI_EVAL_LOCAL_ROOTS) is rewritten under that root so it + lands where `read local://…` resolves — not a literal `local:/` + directory under the cwd (which `Path("local://x")` collapses to). Plain + paths pass through unchanged; any other `scheme://` is rejected.""" + if not isinstance(path, str): + return Path(path) + match = _OMP_INTERNAL_URL_RE.match(path) + if not match: + return Path(path) + scheme = match.group(1).lower() + try: + roots = json.loads(os.environ.get("PI_EVAL_LOCAL_ROOTS") or "{}") + except (ValueError, TypeError): + roots = {} + root = roots.get(scheme) if isinstance(roots, dict) else None + if not root: + raise ValueError(f"Protocol paths are not supported by this helper: {path}") + relative = unquote(match.group(2).replace("\\", "/")) + # Mirror the host `path.resolve`/`resolveLocalUrlToPath`: normalize and + # make absolute WITHOUT realpath'ing symlinks (Path.resolve would turn + # /tmp into /private/tmp and diverge from the read-side resolution). + root_path = os.path.abspath(root) + if relative == "": + return Path(root_path) + rel_path = Path(relative) + if rel_path.is_absolute() or ".." in rel_path.parts: + raise ValueError(f"Unsafe {scheme}:// path (absolute or traversal): {path}") + resolved = os.path.abspath(os.path.join(root_path, relative)) + if resolved != root_path and not resolved.startswith(root_path + os.sep): + raise ValueError(f"{scheme}:// path escapes its root: {path}") + return Path(resolved) + def read(path: str | Path, offset: int = 1, limit: int | None = None) -> str: """Read file contents. offset/limit are 1-indexed line numbers.""" - p = Path(path) + p = _resolve_omp_path(path) data = p.read_text(encoding="utf-8") lines = data.splitlines(keepends=True) if offset > 1 or limit is not None: @@ -69,7 +108,7 @@ if "__omp_prelude_loaded__" not in globals(): def write(path: str | Path, content: str) -> Path: """Write file contents (create parents).""" - p = Path(path) + p = _resolve_omp_path(path) p.parent.mkdir(parents=True, exist_ok=True) p.write_text(content, encoding="utf-8") _emit_status("write", path=str(p), chars=len(content)) @@ -77,7 +116,7 @@ if "__omp_prelude_loaded__" not in globals(): def append(path: str | Path, content: str) -> Path: """Append to file.""" - p = Path(path) + p = _resolve_omp_path(path) p.parent.mkdir(parents=True, exist_ok=True) with p.open("a", encoding="utf-8") as f: f.write(content) diff --git a/packages/coding-agent/src/eval/py/runner.py b/packages/coding-agent/src/eval/py/runner.py index 25bbc11d9..ab6e2ac62 100644 --- a/packages/coding-agent/src/eval/py/runner.py +++ b/packages/coding-agent/src/eval/py/runner.py @@ -819,6 +819,7 @@ _MANAGED_ENV_KEYS = ( "PI_TOOL_BRIDGE_URL", "PI_TOOL_BRIDGE_TOKEN", "PI_TOOL_BRIDGE_SESSION", + "PI_EVAL_LOCAL_ROOTS", ) diff --git a/packages/coding-agent/src/exa/render.ts b/packages/coding-agent/src/exa/render.ts index 7e339d211..79d2b5b95 100644 --- a/packages/coding-agent/src/exa/render.ts +++ b/packages/coding-agent/src/exa/render.ts @@ -93,7 +93,7 @@ export function renderExaResult( const cost = response.costDollars?.total; const time = response.searchTime; - const icon = formatStatusIcon(resultCount > 0 ? "success" : "warning", uiTheme); + const icon = resultCount > 0 ? uiTheme.styledSymbol("tool.exa", "accent") : formatStatusIcon("warning", uiTheme); const metaParts = [formatCount("result", resultCount)]; if (cost !== undefined) metaParts.push(`cost:$${cost.toFixed(4)}`); diff --git a/packages/coding-agent/src/export/ttsr.ts b/packages/coding-agent/src/export/ttsr.ts index 8dba7a399..c0f6425bc 100644 --- a/packages/coding-agent/src/export/ttsr.ts +++ b/packages/coding-agent/src/export/ttsr.ts @@ -5,6 +5,8 @@ * the agent's output. When a match occurs, the stream is aborted, the rule is * injected as a system reminder, and the request is retried. */ +import * as path from "node:path"; +import { AstMatchStrictness, astMatch } from "@oh-my-pi/pi-natives"; import { logger } from "@oh-my-pi/pi-utils"; import type { Rule } from "../capability/rule"; import type { TtsrSettings } from "../config/settings"; @@ -38,6 +40,8 @@ interface TtsrScope { interface TtsrEntry { rule: Rule; conditions: RegExp[]; + /** ast-grep pattern strings; matched only against edit/write tool snapshots. */ + astConditions: string[]; scope: TtsrScope; globalPathGlobs?: Bun.Glob[]; } @@ -70,6 +74,8 @@ export class TtsrManager { readonly #rules = new Map(); readonly #injectionRecords = new Map(); readonly #buffers = new Map(); + /** Last snapshot evaluated for AST conditions, keyed by stream key, to dedupe matcher runs. */ + readonly #lastAstSnapshots = new Map(); #messageCount = 0; constructor(settings?: TtsrSettings) { @@ -302,7 +308,8 @@ export class TtsrManager { } const conditions = this.#compileConditions(rule); - if (conditions.length === 0) { + const astConditions = (rule.astCondition ?? []).map(pattern => pattern.trim()).filter(p => p.length > 0); + if (conditions.length === 0 && astConditions.length === 0) { return false; } @@ -318,6 +325,7 @@ export class TtsrManager { this.#rules.set(rule.name, { rule, conditions, + astConditions, scope, globalPathGlobs, }); @@ -325,6 +333,7 @@ export class TtsrManager { logger.debug("TTSR rule registered", { ruleName: rule.name, conditions: rule.condition, + astConditions: rule.astCondition, scope: rule.scope, globs: rule.globs, }); @@ -359,6 +368,112 @@ export class TtsrManager { return this.#matchBuffer(snapshot, context); } + /** Derive an ast-grep language alias from candidate paths (bare extension, e.g. "ts"), if any. */ + #deriveLang(filePaths: string[] | undefined): string | undefined { + for (const filePath of filePaths ?? []) { + const ext = path.extname(this.#normalizePath(filePath)); + if (ext.length > 1) { + return ext.slice(1).toLowerCase(); + } + } + return undefined; + } + + /** + * Evaluate ast-grep `astCondition` rules against a reconstructed tool snapshot. + * + * Only edit/write tool streams reach here (AST conditions need a language, which + * we infer from the file extension on the tool's path argument). The snapshot is + * matched in memory by the native engine (`astMatch`), so this is async and + * intentionally throttled: identical consecutive snapshots (the common case when + * only non-source arguments change between deltas) are skipped. + */ + async checkAstSnapshot(snapshot: string, context: TtsrMatchContext): Promise { + if (!this.#settings.enabled || context.source !== "tool") { + return []; + } + + const lang = this.#deriveLang(context.filePaths); + if (!lang) { + return []; + } + + const candidates: TtsrEntry[] = []; + for (const [name, entry] of this.#rules) { + if (entry.astConditions.length === 0) { + continue; + } + if ( + !this.#canTrigger(name) || + !this.#matchesScope(entry, context) || + !this.#matchesGlobalPaths(entry, context) + ) { + continue; + } + candidates.push(entry); + } + if (candidates.length === 0) { + return []; + } + + // Throttle: skip re-running the matcher when the source content is unchanged. + const bufferKey = this.#bufferKey(context); + if (this.#lastAstSnapshots.get(bufferKey) === snapshot) { + return []; + } + this.#lastAstSnapshots.set(bufferKey, snapshot); + + const matches: Rule[] = []; + for (const entry of candidates) { + if (await this.#astConditionsMatch(entry.astConditions, snapshot, lang)) { + matches.push(entry.rule); + logger.debug("TTSR ast condition matched", { + ruleName: entry.rule.name, + astConditions: entry.rule.astCondition, + toolName: context.toolName, + filePaths: context.filePaths, + }); + } + } + return matches; + } + + async #astConditionsMatch(patterns: string[], source: string, lang: string): Promise { + try { + const result = await astMatch({ + patterns, + source, + lang, + strictness: AstMatchStrictness.Smart, + limit: 1, + }); + if (result.parseErrors && result.parseErrors.length > 0) { + logger.debug("TTSR ast match reported parse errors", { parseErrors: result.parseErrors }); + } + return result.totalMatches > 0; + } catch (error) { + logger.warn("TTSR ast match failed, treating as no match", { + patterns, + lang, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + } + + /** True when any registered rule carries ast-grep conditions. */ + hasAstRules(): boolean { + if (!this.#settings.enabled) { + return false; + } + for (const entry of this.#rules.values()) { + if (entry.astConditions.length > 0) { + return true; + } + } + return false; + } + #matchBuffer(buffer: string, context: TtsrMatchContext): Rule[] { if (!this.#settings.enabled) { return []; @@ -435,6 +550,7 @@ export class TtsrManager { /** Reset stream buffers (called on new turn). */ resetBuffer(): void { this.#buffers.clear(); + this.#lastAstSnapshots.clear(); } /** Check if any TTSR rules are registered. */ @@ -445,6 +561,11 @@ export class TtsrManager { return this.#rules.size > 0; } + /** All rules currently registered for TTSR monitoring, in registration order. */ + getRules(): Rule[] { + return Array.from(this.#rules.values(), entry => entry.rule); + } + /** Increment message counter (call after each turn). */ incrementMessageCount(): void { this.#messageCount++; diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 17b78e334..8abde9b06 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -28,7 +28,7 @@ import type { TextContent, TSchema, } from "@oh-my-pi/pi-ai"; -import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/utils/oauth/types"; +import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/oauth/types"; import type * as piCodingAgent from "@oh-my-pi/pi-coding-agent"; import type { AutocompleteItem, Component, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui"; import type { logger as PiLogger } from "@oh-my-pi/pi-utils"; @@ -1134,6 +1134,13 @@ export interface ProviderConfig { /** Optional model rewrite hook for credential-aware routing (e.g., enterprise URLs). */ modifyModels?(models: Model[], credentials: OAuthCredentials): Model[]; }; + /** + * Async factory that fetches the live model list from the provider endpoint. + * Runs through the same SQLite model-cache as built-in providers (keyed by + * provider name, default 24 h TTL). Receives the resolved API key (undefined + * when unauthenticated). Mutually exclusive with `models`. + */ + fetchDynamicModels?: (apiKey: string | undefined) => Promise; } /** Configuration for a model within a provider. */ diff --git a/packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts b/packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts index 9426e4d7f..8006e475b 100644 --- a/packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts +++ b/packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts @@ -8,7 +8,7 @@ * entrypoint. Legacy extensions still author parameter schemas as * `Type.Object({ ... })`, so this file is served by `legacy-pi-compat.ts` in * place of the real pi-ai entrypoint whenever a legacy extension imports the - * bare package root. Subpath imports (`@oh-my-pi/pi-ai/utils/oauth`, etc.) + * bare package root. Subpath imports (`@oh-my-pi/pi-ai/oauth`, etc.) * continue to resolve directly against the bundled pi-ai package. * * The `Type` runtime is borrowed from the Zod-backed TypeBox shim that diff --git a/packages/coding-agent/src/extensibility/plugins/doctor.ts b/packages/coding-agent/src/extensibility/plugins/doctor.ts index 5bd600c67..88c0f5b59 100644 --- a/packages/coding-agent/src/extensibility/plugins/doctor.ts +++ b/packages/coding-agent/src/extensibility/plugins/doctor.ts @@ -48,7 +48,7 @@ export function formatDoctorResults(checks: DoctorCheck[]): string { for (const check of checks) { const icon = check.status === "ok" - ? theme.status.success + ? theme.status.enabled : check.status === "warning" ? theme.status.warning : theme.status.error; diff --git a/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts b/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts index 70817b916..e85e63c7d 100644 --- a/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts +++ b/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts @@ -33,10 +33,11 @@ const PI_PACKAGE_ALTERNATION = PI_PACKAGE_NAMES.join("|"); // bundled copy. Add new entries as `pkg/from -> pkg/to` whenever a plugin // surfaces another upstream-only subpath that breaks resolution. const PI_SUBPATH_REMAPS: ReadonlyMap = new Map([ - // `@mariozechner/pi-ai/oauth` re-exported `./utils/oauth/index.js`. - // Our pi-ai keeps the implementation under `utils/oauth` but never added a - // root-level re-export, so map the upstream subpath onto it directly. - ["pi-ai/oauth", "pi-ai/utils/oauth"], + // (currently empty) Upstream `@mariozechner/pi-ai/oauth` re-exported + // `./utils/oauth/index.js`. Our pi-ai now exposes the same surface at the + // real `@oh-my-pi/pi-ai/oauth` export, so the legacy subpath canonicalizes + // straight to it with no rewrite. Add `from -> to` entries here whenever a + // future upstream-only subpath surfaces that breaks resolution. ]); const LEGACY_PI_SPECIFIER_FILTER = new RegExp(`^@(?:${PI_SCOPE_ALTERNATION})/(?:${PI_PACKAGE_ALTERNATION})(?:/.*)?$`); @@ -119,7 +120,7 @@ const TYPEBOX_SHIM_PATH = BUNFS_PACKAGE_ROOT // longer satisfies those imports. The override below redirects only the bare // pi-ai package root onto a sibling shim that re-exports the canonical surface // plus the borrowed `Type` runtime from the Zod-backed TypeBox shim. Subpath -// imports such as `@oh-my-pi/pi-ai/utils/oauth` continue to resolve directly +// imports such as `@oh-my-pi/pi-ai/oauth` continue to resolve directly // against the bundled pi-ai package. const LEGACY_PI_AI_SHIM_PATH = BUNFS_PACKAGE_ROOT ? bunfsPath("coding-agent", "src", "extensibility", "legacy-pi-ai-shim.js") diff --git a/packages/coding-agent/src/goals/tools/goal-tool.ts b/packages/coding-agent/src/goals/tools/goal-tool.ts index 94b9545bf..39505aed4 100644 --- a/packages/coding-agent/src/goals/tools/goal-tool.ts +++ b/packages/coding-agent/src/goals/tools/goal-tool.ts @@ -209,7 +209,7 @@ export const goalToolRenderer = { const header = renderStatusLine( { - icon: "success", + iconOverride: uiTheme.styledSymbol("tool.goal", "accent"), title: "Goal", description, badge: { label: goal.status, color: goalBadgeColor(goal.status) }, diff --git a/packages/coding-agent/src/internal-urls/local-protocol.ts b/packages/coding-agent/src/internal-urls/local-protocol.ts index 503566cf5..5e262b04a 100644 --- a/packages/coding-agent/src/internal-urls/local-protocol.ts +++ b/packages/coding-agent/src/internal-urls/local-protocol.ts @@ -133,6 +133,19 @@ export function resolveLocalUrlToPath(input: string | InternalUrl, options: Loca return resolved; } +/** + * On-disk roots the eval helpers (`read`/`write`/`append`) substitute for + * internal-URL schemes so e.g. `write("local://x.md")` lands where a later + * `read local://x.md` resolves — instead of a literal `local:/` directory under + * the cwd (a stdlib `pathlib.Path`/`path.resolve` collapses `local://` to + * `local:/`). Keyed by scheme without the `://`. Currently only `local`, but the + * shape is a map so additional file-backed schemes can be added without + * re-plumbing the worker boundary. + */ +export function buildEvalUrlRoots(options: LocalProtocolOptions): Record { + return { local: resolveLocalRoot(options) }; +} + /** * Protocol handler for local:// URLs. * diff --git a/packages/coding-agent/src/lsp/render.ts b/packages/coding-agent/src/lsp/render.ts index f62a9e0dd..82fcb30b4 100644 --- a/packages/coding-agent/src/lsp/render.ts +++ b/packages/coding-agent/src/lsp/render.ts @@ -166,15 +166,17 @@ export function renderResult( } else if (result.details?.action === "diagnostics" && text === "OK") { label = "Diagnostics"; state = "success"; - bodyLines = [`${theme.styledSymbol("status.success", "success")} ${theme.fg("dim", "OK")}`]; + bodyLines = [`${theme.styledSymbol("tool.lsp", "accent")} ${theme.fg("dim", "OK")}`]; } else { label = "Response"; bodyLines = renderGeneric(text, lines, expanded, theme); } const actionLabel = (request?.action ?? result.details?.action ?? label.toLowerCase()).replace(/_/g, " "); - const status = isPartial ? "running" : result.isError ? "error" : "success"; - const icon = formatStatusIcon(status, theme, spinnerFrame); + const isSuccess = !isPartial && !result.isError; + const icon = isSuccess + ? theme.styledSymbol("tool.lsp", "accent") + : formatStatusIcon(isPartial ? "running" : "error", theme, spinnerFrame); const header = `${icon} LSP ${actionLabel}`; return outputBlock.render( @@ -325,7 +327,7 @@ function renderDiagnostics( ? theme.styledSymbol("status.error", "error") : warnCount > 0 ? theme.styledSymbol("status.warning", "warning") - : theme.styledSymbol("status.success", "success"); + : theme.styledSymbol("tool.lsp", "accent"); const meta: string[] = []; if (errorCount > 0) meta.push(`${errorCount} error${errorCount !== 1 ? "s" : ""}`); @@ -407,7 +409,7 @@ function renderDiagnostics( function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded: boolean, theme: Theme): string[] { const refCount = Number.parseInt(refMatch[1], 10); const icon = - refCount > 0 ? theme.styledSymbol("status.success", "success") : theme.styledSymbol("status.warning", "warning"); + refCount > 0 ? theme.styledSymbol("tool.lsp", "accent") : theme.styledSymbol("status.warning", "warning"); const locLines = lines.filter(l => /^\s*\S+:\d+:\d+/.test(l)); @@ -598,7 +600,7 @@ function renderGeneric(text: string, lines: string[], expanded: boolean, theme: hasError && !hasSuccess ? theme.styledSymbol("status.error", "error") : hasSuccess && !hasError - ? theme.styledSymbol("status.success", "success") + ? theme.styledSymbol("tool.lsp", "accent") : theme.styledSymbol("status.info", "accent"); if (expanded) { diff --git a/packages/coding-agent/src/mcp/oauth-flow.ts b/packages/coding-agent/src/mcp/oauth-flow.ts index efcef86a0..7ac50765a 100644 --- a/packages/coding-agent/src/mcp/oauth-flow.ts +++ b/packages/coding-agent/src/mcp/oauth-flow.ts @@ -5,9 +5,9 @@ * by providing authorization URL, token URL, and client credentials. */ -import type { OAuthCallbackFlowOptions } from "@oh-my-pi/pi-ai/utils/oauth/callback-server"; -import { OAuthCallbackFlow } from "@oh-my-pi/pi-ai/utils/oauth/callback-server"; -import type { OAuthController, OAuthCredentials } from "@oh-my-pi/pi-ai/utils/oauth/types"; +import type { OAuthCallbackFlowOptions } from "@oh-my-pi/pi-ai/oauth/callback-server"; +import { OAuthCallbackFlow } from "@oh-my-pi/pi-ai/oauth/callback-server"; +import type { OAuthController, OAuthCredentials } from "@oh-my-pi/pi-ai/oauth/types"; const DEFAULT_PORT = 3000; const CALLBACK_PATH = "/callback"; diff --git a/packages/coding-agent/src/mcp/render.ts b/packages/coding-agent/src/mcp/render.ts index 94b3be5db..68fcfa5dd 100644 --- a/packages/coding-agent/src/mcp/render.ts +++ b/packages/coding-agent/src/mcp/render.ts @@ -53,7 +53,13 @@ export function renderMCPResult( const lines: string[] = []; const isError = result.isError ?? result.details?.isError ?? false; const title = result.details ? `${result.details.serverName}/${result.details.mcpToolName}` : "MCP"; - lines.push(renderStatusLine({ icon: isError ? "error" : "success", title }, theme)); + const success = !isError; + lines.push( + renderStatusLine( + success ? { iconOverride: theme.styledSymbol("tool.mcp", "accent"), title } : { icon: "error", title }, + theme, + ), + ); // Args section (when expanded) if (expanded && args && typeof args === "object" && Object.keys(args).length > 0) { diff --git a/packages/coding-agent/src/modes/components/custom-editor.ts b/packages/coding-agent/src/modes/components/custom-editor.ts index f0742da2e..73e42aa46 100644 --- a/packages/coding-agent/src/modes/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/components/custom-editor.ts @@ -1,6 +1,6 @@ import { addKeyAliases, canonicalKeyId, Editor, type KeyId, parseKey, parseKittySequence } from "@oh-my-pi/pi-tui"; import type { AppKeybinding } from "../../config/keybindings"; -import { imageReferenceHyperlink, renderImageReferences } from "../image-references"; +import { imageReferenceHyperlink, PLACEHOLDER_REGEX, renderPlaceholders } from "../image-references"; import { highlightMagicKeywords } from "../magic-keywords"; import { theme } from "../theme/theme"; @@ -76,16 +76,22 @@ export function extractBracketedImagePastePath(data: string): string | undefined export class CustomEditor extends Editor { imageLinks?: readonly (string | undefined)[]; + /** Treat image/paste markers as indivisible: a stray backspace deletes the whole token + * instead of corrupting `[Paste #1, +30 lines]` into plain text. */ + override atomicTokenPattern = PLACEHOLDER_REGEX; + /** Gradient-highlight the "ultrathink" / "orchestrate" / "workflowz" keywords as the user types * them, skipping any occurrence inside code spans, fenced blocks, or XML sections. Also make * pasted image placeholders visually distinct and hyperlink them once their blob file exists. */ decorateText = (text: string): string => - renderImageReferences(text, { + renderPlaceholders(text, { renderText: value => highlightMagicKeywords(value), - renderReference: (value, index) => - imageReferenceHyperlink(value, index, this.imageLinks, label => - theme.fg("accent", `\x1b[1m\x1b[4m${label}\x1b[24m\x1b[22m`), - ), + renderReference: (value, kind, index) => + kind === "image" + ? imageReferenceHyperlink(value, index, this.imageLinks, label => + theme.fg("accent", `\x1b[1m\x1b[4m${label}\x1b[24m\x1b[22m`), + ) + : theme.fg("accent", `\x1b[1m${value}\x1b[22m`), }); onEscape?: () => void; onClear?: () => void; diff --git a/packages/coding-agent/src/modes/components/login-dialog.ts b/packages/coding-agent/src/modes/components/login-dialog.ts index 9fb44b4b5..628cb8691 100644 --- a/packages/coding-agent/src/modes/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/components/login-dialog.ts @@ -1,4 +1,4 @@ -import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth"; +import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; import { Container, getKeybindings, Input, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; import { theme } from "../../modes/theme/theme"; import { openPath } from "../../utils/open"; diff --git a/packages/coding-agent/src/modes/components/oauth-selector.ts b/packages/coding-agent/src/modes/components/oauth-selector.ts index b837b4ce2..e4436a29e 100644 --- a/packages/coding-agent/src/modes/components/oauth-selector.ts +++ b/packages/coding-agent/src/modes/components/oauth-selector.ts @@ -1,5 +1,5 @@ -import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth"; -import type { OAuthProviderInfo } from "@oh-my-pi/pi-ai/utils/oauth/types"; +import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; +import type { OAuthProviderInfo } from "@oh-my-pi/pi-ai/oauth/types"; import { Container, extractPrintableText, @@ -179,10 +179,10 @@ export class OAuthSelectorComponent extends Container { return theme.fg("error", ` ${theme.status.error} invalid`) + source; } if (state === "valid") { - return theme.fg("success", ` ${theme.status.success} logged in`) + source; + return theme.fg("success", ` ${theme.status.enabled} logged in`) + source; } return this.#hasSelectableAuth(providerId) - ? theme.fg("success", ` ${theme.status.success} logged in`) + source + ? theme.fg("success", ` ${theme.status.enabled} logged in`) + source : ""; } diff --git a/packages/coding-agent/src/modes/components/read-tool-group.ts b/packages/coding-agent/src/modes/components/read-tool-group.ts index c468b74c7..5225a42a6 100644 --- a/packages/coding-agent/src/modes/components/read-tool-group.ts +++ b/packages/coding-agent/src/modes/components/read-tool-group.ts @@ -54,6 +54,10 @@ type ReadToolResultDetails = { }; conflictCount?: number; displayReadTargets?: unknown; + displayContent?: { + text?: string; + startLine?: number; + }; meta?: { source?: { type?: string; @@ -373,10 +377,13 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa typeof details?.conflictCount === "number" && details.conflictCount > 0 ? details.conflictCount : undefined; entry.conflictCount = conflictCount; entry.status = result.isError ? "error" : suffixResolution ? "warning" : "success"; - // Store the text content for preview/expanded display + // Store clean display content for preview/expanded display when the read + // tool provides it; fall back to model-facing text for legacy results. + const displayContent = + typeof details?.displayContent?.text === "string" ? details.displayContent.text : undefined; const textContent = result.content?.find(c => c.type === "text")?.text; - if (textContent !== undefined) { - entry.contentText = textContent; + if (displayContent !== undefined || textContent !== undefined) { + entry.contentText = displayContent ?? textContent; } this.#updateDisplay(); } diff --git a/packages/coding-agent/src/modes/components/status-line.ts b/packages/coding-agent/src/modes/components/status-line/component.ts similarity index 94% rename from packages/coding-agent/src/modes/components/status-line.ts rename to packages/coding-agent/src/modes/components/status-line/component.ts index 03799e2ef..63e34d120 100644 --- a/packages/coding-agent/src/modes/components/status-line.ts +++ b/packages/coding-agent/src/modes/components/status-line/component.ts @@ -4,46 +4,24 @@ import { estimateTokens } from "@oh-my-pi/pi-agent-core/compaction"; import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; import { formatCount, getProjectDir } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; -import { settings } from "../../config/settings"; -import type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle } from "../../config/settings-schema"; -import { theme } from "../../modes/theme/theme"; -import type { AgentSession } from "../../session/agent-session"; -import * as git from "../../utils/git"; -import { getSessionAccentAnsi, getSessionAccentHex } from "../../utils/session-color"; -import { sanitizeStatusText } from "../shared"; -import { computeNonMessageTokens } from "../utils/context-usage"; -import { - canReuseCachedPr, - createPrCacheContext, - isSamePrCacheContext, - type PrCacheContext, -} from "./status-line/git-utils"; -import { getPreset } from "./status-line/presets"; -import { renderSegment, type SegmentContext } from "./status-line/segments"; -import { getSeparator } from "./status-line/separators"; -import { calculateTokensPerSecond } from "./status-line/token-rate"; - -export interface StatusLineSegmentOptions { - model?: { showThinkingLevel?: boolean }; - path?: { abbreviate?: boolean; maxLength?: number; stripWorkPrefix?: boolean }; - git?: { showBranch?: boolean; showStaged?: boolean; showUnstaged?: boolean; showUntracked?: boolean }; - time?: { format?: "12h" | "24h"; showSeconds?: boolean }; -} - -export interface StatusLineSettings { - preset?: StatusLinePreset; - leftSegments?: StatusLineSegmentId[]; - rightSegments?: StatusLineSegmentId[]; - separator?: StatusLineSeparatorStyle; - segmentOptions?: StatusLineSegmentOptions; - showHookStatus?: boolean; - sessionAccent?: boolean; -} - -export type EffectiveStatusLineSettings = Required< - Pick -> & - StatusLineSettings; +import { settings } from "../../../config/settings"; +import type { AgentSession } from "../../../session/agent-session"; +import * as git from "../../../utils/git"; +import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/session-color"; +import { sanitizeStatusText } from "../../shared"; +import { theme } from "../../theme/theme"; +import { computeNonMessageTokens } from "../../utils/context-usage"; +import { canReuseCachedPr, createPrCacheContext, isSamePrCacheContext, type PrCacheContext } from "./git-utils"; +import { getPreset } from "./presets"; +import { renderSegment, type SegmentContext } from "./segments"; +import { getSeparator } from "./separators"; +import { calculateTokensPerSecond } from "./token-rate"; +import type { + EffectiveStatusLineSettings, + StatusLineSegmentId, + StatusLineSegmentOptions, + StatusLineSettings, +} from "./types"; // ═══════════════════════════════════════════════════════════════════════════ // Per-message token cache diff --git a/packages/coding-agent/src/modes/components/status-line/index.ts b/packages/coding-agent/src/modes/components/status-line/index.ts index 79207227d..4719d6ee8 100644 --- a/packages/coding-agent/src/modes/components/status-line/index.ts +++ b/packages/coding-agent/src/modes/components/status-line/index.ts @@ -1,3 +1,4 @@ +export * from "./component"; export * from "./presets"; export * from "./segments"; export * from "./separators"; diff --git a/packages/coding-agent/src/modes/components/status-line/types.ts b/packages/coding-agent/src/modes/components/status-line/types.ts index 274598d96..a6bb971d5 100644 --- a/packages/coding-agent/src/modes/components/status-line/types.ts +++ b/packages/coding-agent/src/modes/components/status-line/types.ts @@ -1,14 +1,29 @@ import type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle } from "../../../config/settings-schema"; import type { AgentSession } from "../../../session/agent-session"; -import type { StatusLineSegmentOptions, StatusLineSettings } from "../status-line"; -export type { - StatusLinePreset, - StatusLineSegmentId, - StatusLineSegmentOptions, - StatusLineSeparatorStyle, - StatusLineSettings, -}; +export type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle }; + +export interface StatusLineSegmentOptions { + model?: { showThinkingLevel?: boolean }; + path?: { abbreviate?: boolean; maxLength?: number; stripWorkPrefix?: boolean }; + git?: { showBranch?: boolean; showStaged?: boolean; showUnstaged?: boolean; showUntracked?: boolean }; + time?: { format?: "12h" | "24h"; showSeconds?: boolean }; +} + +export interface StatusLineSettings { + preset?: StatusLinePreset; + leftSegments?: StatusLineSegmentId[]; + rightSegments?: StatusLineSegmentId[]; + separator?: StatusLineSeparatorStyle; + segmentOptions?: StatusLineSegmentOptions; + showHookStatus?: boolean; + sessionAccent?: boolean; +} + +export type EffectiveStatusLineSettings = Required< + Pick +> & + StatusLineSettings; // ═══════════════════════════════════════════════════════════════════════════ // Segment Rendering diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts index 05fd735c8..e63785818 100644 --- a/packages/coding-agent/src/modes/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/components/tool-execution.ts @@ -914,7 +914,7 @@ export class ToolExecutionComponent extends Container { */ #formatToolExecution(): string { const lines: string[] = []; - const icon = this.#isPartial ? "pending" : this.#result?.isError ? "error" : "success"; + const icon = this.#isPartial ? "pending" : this.#result?.isError ? "error" : "done"; lines.push(renderStatusLine({ icon, title: this.#toolLabel }, theme)); const argsObject = this.#args && typeof this.#args === "object" ? (this.#args as Record) : null; diff --git a/packages/coding-agent/src/modes/components/transcript-container.ts b/packages/coding-agent/src/modes/components/transcript-container.ts index 5765a3712..66c1910aa 100644 --- a/packages/coding-agent/src/modes/components/transcript-container.ts +++ b/packages/coding-agent/src/modes/components/transcript-container.ts @@ -71,9 +71,7 @@ function commonPrefixLength(prev: string[], cur: string[]): number { } function commonSuffixLength(prev: string[], cur: string[], prefixLength: number): number { - const prevLimit = prev.length - prefixLength; - const curLimit = cur.length - prefixLength; - const limit = Math.min(prevLimit, curLimit); + const limit = Math.min(prev.length - prefixLength, cur.length - prefixLength); let i = 0; while (i < limit && prev[prev.length - 1 - i] === cur[cur.length - 1 - i]) i++; return i; @@ -95,14 +93,23 @@ function deriveLiveCommitState( const staticRender = prefixLength === previous.lines.length && prefixLength === current.length; if (!staticRender) { const suffixLength = commonSuffixLength(previous.lines, current, prefixLength); - const stablePreviousLength = prefixLength + suffixLength; - const appendGrew = - previous.lines.length > 0 && - current.length > previous.lines.length && - stablePreviousLength >= previous.lines.length; - if (appendGrew && !volatile) { + // Append-only growth never rewrites a row that may already have scrolled + // into native scrollback; it only grows the block at/near its tail. Three + // shapes qualify: a pure bottom append, an insertion above stable trailing + // chrome (a streaming tool's footer/border), and an in-place extension of + // the current line by one streamed token (line count unchanged). The first + // two preserve every previous row across a matching prefix + suffix; the + // last leaves a single divergent previous row that the current row merely + // lengthens. A divergent interior row that is genuinely rewritten means the + // block re-laid-out committed content — volatile, and never committed. + const preservedEveryRow = prefixLength + suffixLength >= previous.lines.length; + const tailExtendedInPlace = + prefixLength + suffixLength === previous.lines.length - 1 && + prefixLength < current.length && + current[prefixLength]!.startsWith(previous.lines[prefixLength]!); + if ((preservedEveryRow || tailExtendedInPlace) && current.length >= previous.lines.length && !volatile) { appendOnly = true; - } else if (stablePreviousLength < previous.lines.length) { + } else if (!preservedEveryRow && !tailExtendedInPlace) { volatile = true; appendOnly = false; } diff --git a/packages/coding-agent/src/modes/components/user-message.ts b/packages/coding-agent/src/modes/components/user-message.ts index 6a1c8e81c..dc2614cf4 100644 --- a/packages/coding-agent/src/modes/components/user-message.ts +++ b/packages/coding-agent/src/modes/components/user-message.ts @@ -1,6 +1,6 @@ import { Container, Markdown } from "@oh-my-pi/pi-tui"; import { getMarkdownTheme, theme } from "../../modes/theme/theme"; -import { imageReferenceHyperlink, renderImageReferences } from "../image-references"; +import { imageReferenceHyperlink, renderPlaceholders } from "../image-references"; import { highlightMagicKeywords } from "../magic-keywords"; // OSC 133 shell integration: marks prompt zones for terminal multiplexers @@ -26,9 +26,12 @@ export class UserMessageComponent extends Container { : (value: string) => theme.fg("userMessageText", highlightMagicKeywords(value, keywordReset)); const imageLabel = (value: string) => theme.fg("accent", `\x1b[1m\x1b[4m${value}\x1b[24m\x1b[22m`); const color = (value: string) => - renderImageReferences(value, { + renderPlaceholders(value, { renderText: baseText, - renderReference: (label, index) => imageReferenceHyperlink(label, index, imageLinks, imageLabel), + renderReference: (label, kind, index) => + kind === "image" + ? imageReferenceHyperlink(label, index, imageLinks, imageLabel) + : theme.fg("accent", `\x1b[1m${label}\x1b[22m`), }); this.addChild( new Markdown(text, 1, 1, getMarkdownTheme(), { diff --git a/packages/coding-agent/src/modes/components/welcome.ts b/packages/coding-agent/src/modes/components/welcome.ts index a690c36a4..1517cd333 100644 --- a/packages/coding-agent/src/modes/components/welcome.ts +++ b/packages/coding-agent/src/modes/components/welcome.ts @@ -185,7 +185,7 @@ export class WelcomeComponent implements Component { for (const server of this.lspServers) { const icon = server.status === "ready" - ? theme.styledSymbol("status.success", "success") + ? theme.styledSymbol("status.enabled", "success") : server.status === "connecting" ? theme.styledSymbol("status.pending", "muted") : theme.styledSymbol("status.error", "error"); 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 31d6e7cc0..3b6abe82e 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -30,6 +30,11 @@ export class ExtensionUiController { #extensionTerminalInputUnsubscribers = new Set<() => void>(); #hookWidgetsAbove = new Map(); #hookWidgetsBelow = new Map(); + // Single-file dialog surface (`editorContainer` + focus) is shared by the + // selector / input / editor modals, so only one may be presented at a time; + // the rest queue. See `#presentDialog`. + #dialogActive = false; + #dialogQueue: Array<() => void> = []; constructor(private ctx: InteractiveModeContext) {} /** @@ -528,58 +533,47 @@ export class ExtensionUiController { dialogOptions?: InteractiveSelectorDialogOptions, extra?: { slider?: HookSelectorSlider }, ): Promise { - const { promise, finish, attachAbort } = this.#createHookDialogState( - () => this.hideHookSelector(), - dialogOptions?.signal, - ); - const maxVisible = Math.max(4, Math.min(15, this.ctx.ui.terminal.rows - 12)); - this.ctx.hookSelector = new HookSelectorComponent( - title, - options, - option => { - this.hideHookSelector(); - finish(option); - }, - () => { - this.hideHookSelector(); - finish(undefined); - }, - { - onLeft: dialogOptions?.onLeft - ? () => { - this.hideHookSelector(); - dialogOptions.onLeft?.(); - finish(undefined); - } - : undefined, - onRight: dialogOptions?.onRight - ? () => { - this.hideHookSelector(); - dialogOptions.onRight?.(); - finish(undefined); - } - : undefined, - onExternalEditor: dialogOptions?.onExternalEditor, - helpText: dialogOptions?.helpText, - initialIndex: dialogOptions?.initialIndex, - timeout: dialogOptions?.timeout, - onTimeout: dialogOptions?.onTimeout, - tui: this.ctx.ui, - outline: dialogOptions?.outline, - disabledIndices: dialogOptions?.disabledIndices, - selectionMarker: dialogOptions?.selectionMarker, - checkedIndices: dialogOptions?.checkedIndices, - markableCount: dialogOptions?.markableCount, - maxVisible, - slider: extra?.slider, - }, - ); - this.ctx.editorContainer.clear(); - this.ctx.editorContainer.addChild(this.ctx.hookSelector); - this.ctx.ui.setFocus(this.ctx.hookSelector); - this.ctx.ui.requestRender(); - attachAbort(); - return promise; + return this.#presentDialog(dialogOptions?.signal, settle => { + const maxVisible = Math.max(4, Math.min(15, this.ctx.ui.terminal.rows - 12)); + this.ctx.hookSelector = new HookSelectorComponent( + title, + options, + option => settle(option), + () => settle(undefined), + { + onLeft: dialogOptions?.onLeft + ? () => { + dialogOptions.onLeft?.(); + settle(undefined); + } + : undefined, + onRight: dialogOptions?.onRight + ? () => { + dialogOptions.onRight?.(); + settle(undefined); + } + : undefined, + onExternalEditor: dialogOptions?.onExternalEditor, + helpText: dialogOptions?.helpText, + initialIndex: dialogOptions?.initialIndex, + timeout: dialogOptions?.timeout, + onTimeout: dialogOptions?.onTimeout, + tui: this.ctx.ui, + outline: dialogOptions?.outline, + disabledIndices: dialogOptions?.disabledIndices, + selectionMarker: dialogOptions?.selectionMarker, + checkedIndices: dialogOptions?.checkedIndices, + markableCount: dialogOptions?.markableCount, + maxVisible, + slider: extra?.slider, + }, + ); + this.ctx.editorContainer.clear(); + this.ctx.editorContainer.addChild(this.ctx.hookSelector); + this.ctx.ui.setFocus(this.ctx.hookSelector); + this.ctx.ui.requestRender(); + return () => this.hideHookSelector(); + }); } /** * Hide the hook selector. @@ -609,33 +603,24 @@ export class ExtensionUiController { placeholder?: string, dialogOptions?: ExtensionUIDialogOptions, ): Promise { - const { promise, finish, attachAbort } = this.#createHookDialogState( - () => this.hideHookInput(), - dialogOptions?.signal, - ); - this.ctx.hookInput = new HookInputComponent( - title, - placeholder, - value => { - this.hideHookInput(); - finish(value); - }, - () => { - this.hideHookInput(); - finish(undefined); - }, - { - timeout: dialogOptions?.timeout, - onTimeout: dialogOptions?.onTimeout, - tui: this.ctx.ui, - }, - ); - this.ctx.editorContainer.clear(); - this.ctx.editorContainer.addChild(this.ctx.hookInput); - this.ctx.ui.setFocus(this.ctx.hookInput); - this.ctx.ui.requestRender(); - attachAbort(); - return promise; + return this.#presentDialog(dialogOptions?.signal, settle => { + this.ctx.hookInput = new HookInputComponent( + title, + placeholder, + value => settle(value), + () => settle(undefined), + { + timeout: dialogOptions?.timeout, + onTimeout: dialogOptions?.onTimeout, + tui: this.ctx.ui, + }, + ); + this.ctx.editorContainer.clear(); + this.ctx.editorContainer.addChild(this.ctx.hookInput); + this.ctx.ui.setFocus(this.ctx.hookInput); + this.ctx.ui.requestRender(); + return () => this.hideHookInput(); + }); } /** @@ -659,31 +644,21 @@ export class ExtensionUiController { dialogOptions?: ExtensionUIDialogOptions, editorOptions?: { promptStyle?: boolean }, ): Promise { - const { promise, finish, attachAbort } = this.#createHookDialogState( - () => this.hideHookEditor(), - dialogOptions?.signal, - ); - this.ctx.hookEditor = new HookEditorComponent( - this.ctx.ui, - title, - prefill, - value => { - this.hideHookEditor(); - finish(value); - }, - () => { - this.hideHookEditor(); - finish(undefined); - }, - editorOptions, - ); - - this.ctx.editorContainer.clear(); - this.ctx.editorContainer.addChild(this.ctx.hookEditor); - this.ctx.ui.setFocus(this.ctx.hookEditor); - this.ctx.ui.requestRender(); - attachAbort(); - return promise; + return this.#presentDialog(dialogOptions?.signal, settle => { + this.ctx.hookEditor = new HookEditorComponent( + this.ctx.ui, + title, + prefill, + value => settle(value), + () => settle(undefined), + editorOptions, + ); + this.ctx.editorContainer.clear(); + this.ctx.editorContainer.addChild(this.ctx.hookEditor); + this.ctx.ui.setFocus(this.ctx.hookEditor); + this.ctx.ui.requestRender(); + return () => this.hideHookEditor(); + }); } /** @@ -834,37 +809,78 @@ export class ExtensionUiController { } } - #createHookDialogState( - hide: () => void, + /** + * Present a modal dialog on the shared editor surface, serializing against any + * dialog already open. `present` builds the component, swaps it into + * `editorContainer`, steals focus, and returns a `hide` closure; it is invoked + * with a single `settle` callback that the component fires on submit/cancel. + * + * Because selector / input / editor all clear `editorContainer` and re-focus, + * showing a second one while the first is open would orphan the first — its + * promise would hang until the caller's signal aborts. So at most one dialog is + * presented at a time and the rest queue (FIFO). `settle` (or an abort) hides + * the current dialog and hands the surface to the next queued request. A request + * whose signal aborts before its turn resolves `undefined` and is never shown. + */ + #presentDialog( signal: AbortSignal | undefined, - ): { - promise: Promise; - finish: (value: string | undefined) => void; - attachAbort: () => void; - } { - const { promise, resolve } = Promise.withResolvers(); + present: (settle: (value: string | undefined) => void) => () => void, + ): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); let settled = false; - const onAbort = () => { - hide(); - if (!settled) { - settled = true; - resolve(undefined); - } - }; - const finish = (value: string | undefined) => { + let started = false; + let hide: (() => void) | undefined; + + function onAbort(): void { + settle(undefined); + } + + const settle = (value: string | undefined): void => { if (settled) return; settled = true; signal?.removeEventListener("abort", onAbort); + if (started) { + hide?.(); + this.#dialogActive = false; + this.#advanceDialogQueue(); + } resolve(value); }; - const attachAbort = () => { - if (!signal) return; - if (signal.aborted) { - onAbort(); - } else { - signal.addEventListener("abort", onAbort, { once: true }); + + const startPresentation = (): void => { + if (settled) { + // Aborted before its turn arrived — never present, hand off the surface. + this.#advanceDialogQueue(); + return; + } + started = true; + this.#dialogActive = true; + try { + hide = present(settle); + } catch (error) { + settled = true; + signal?.removeEventListener("abort", onAbort); + this.#dialogActive = false; + reject(error); + this.#advanceDialogQueue(); } }; - return { promise, finish, attachAbort }; + + if (signal?.aborted) { + resolve(undefined); + return promise; + } + signal?.addEventListener("abort", onAbort, { once: true }); + + if (this.#dialogActive) { + this.#dialogQueue.push(startPresentation); + } else { + startPresentation(); + } + return promise; + } + + #advanceDialogQueue(): void { + this.#dialogQueue.shift()?.(); } } diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 78ea6edee..2a2aff7d3 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -397,11 +397,8 @@ export class InputController { // Queue input during compaction if (this.ctx.session.isCompacting) { - if (this.ctx.pendingImages.length > 0) { - this.ctx.showStatus("Compaction in progress. Retry after it completes to send images."); - return; - } - this.ctx.queueCompactionMessage(text, "steer"); + const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined; + this.ctx.queueCompactionMessage(text, "steer", images); return; } @@ -636,7 +633,8 @@ export class InputController { // the queued entry is later re-parsed into a skill invocation is a // separate concern owned by the compaction-resume path. if (this.ctx.session.isCompacting) { - this.ctx.queueCompactionMessage(text, "followUp"); + const images = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined; + this.ctx.queueCompactionMessage(text, "followUp", images); return; } @@ -657,11 +655,20 @@ export class InputController { return; } + // Forward any pending clipboard-pasted images alongside the queued text; + // otherwise the follow-up would drop the image (mirrors the Enter/steer path). + const images = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined; + if (this.ctx.session.isStreaming) { this.ctx.editor.addToHistory(text); this.ctx.editor.setText(""); - await this.ctx.withLocalSubmission(text, () => - this.ctx.session.prompt(text, { streamingBehavior: "followUp" }), + this.ctx.editor.imageLinks = undefined; + this.ctx.pendingImages = []; + this.ctx.pendingImageLinks = []; + await this.ctx.withLocalSubmission( + text, + () => this.ctx.session.prompt(text, { streamingBehavior: "followUp", images }), + { imageCount: images?.length ?? 0 }, ); this.ctx.updatePendingMessagesDisplay(); this.ctx.ui.requestRender(); @@ -671,7 +678,12 @@ export class InputController { // Not streaming — just submit normally this.ctx.editor.addToHistory(text); this.ctx.editor.setText(""); - await this.ctx.withLocalSubmission(text, () => this.ctx.session.prompt(text)); + this.ctx.editor.imageLinks = undefined; + this.ctx.pendingImages = []; + this.ctx.pendingImageLinks = []; + await this.ctx.withLocalSubmission(text, () => this.ctx.session.prompt(text, { images }), { + imageCount: images?.length ?? 0, + }); } restoreQueuedMessagesToEditor(options?: { abort?: boolean; currentText?: string }): number { @@ -717,10 +729,24 @@ export class InputController { this.ctx.pendingImageLinks.push(imageLink); this.ctx.editor.imageLinks = this.ctx.pendingImageLinks; const imageNum = this.ctx.pendingImages.length; - this.ctx.editor.insertText(`[Image #${imageNum}] `); + const dims = await this.#imageDimensions(imageData); + const label = dims ? `[Image #${imageNum}, ${dims.width}x${dims.height}]` : `[Image #${imageNum}]`; + this.ctx.editor.insertText(`${label} `); this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true }); } + /** Probe pixel dimensions for the marker label (`[Image #N, WxH]`). Returns undefined when the + * header can't be decoded, so the caller falls back to a bare `[Image #N]`. */ + async #imageDimensions(image: ImageContent): Promise<{ width: number; height: number } | undefined> { + try { + const { width, height } = await new Bun.Image(Buffer.from(image.data, "base64")).metadata(); + if (width && height) return { width, height }; + } catch { + // Unknown/corrupt header — fall back to a bare label. + } + return undefined; + } + async #normalizeAndInsertPastedImage(image: ImageContent, unsupportedMessage: string): Promise { let imageData = await ensureSupportedImageInput(image); if (!imageData) { diff --git a/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts b/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts index c63e57162..2aec08749 100644 --- a/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts @@ -802,7 +802,7 @@ export class MCPCommandController { await this.ctx.session.refreshMCPTools(this.ctx.mcpManager.getTools()); } if (state === "connected") { - block.setStatus(theme.fg("success", `✓ Connected to "${name}"`)); + block.setStatus(theme.fg("success", `${theme.status.enabled} Connected to "${name}"`)); } else if (state === "connecting") { block.setStatus(theme.fg("muted", `◌ "${name}" is still connecting...`)); } else { @@ -873,10 +873,10 @@ export class MCPCommandController { // Show success message const scopeLabel = scope === "user" ? "user" : "project"; - const lines = ["", theme.fg("success", `✓ Added server "${name}" to ${scopeLabel} config`), ""]; + const lines = ["", theme.fg("success", `+ Added server "${name}" to ${scopeLabel} config`), ""]; if (isConnected) { - lines.push(theme.fg("success", `✓ Successfully connected to server`)); + lines.push(theme.fg("success", `${theme.status.enabled} Successfully connected to server`)); lines.push(""); } else if (isConnecting) { lines.push(theme.fg("muted", `◌ Server is connecting in background...`)); @@ -1096,7 +1096,7 @@ export class MCPCommandController { // Reload MCP manager await this.#reloadMCP(); - this.#showMessage(["", theme.fg("success", `✓ Removed server "${name}" from ${scope} config`), ""].join("\n")); + this.#showMessage(["", theme.fg("success", `- Removed server "${name}" from ${scope} config`), ""].join("\n")); } catch (error) { this.ctx.showError(`Failed to remove server: ${error instanceof Error ? error.message : String(error)}`); } @@ -1156,7 +1156,7 @@ export class MCPCommandController { const lines = [ "", - theme.fg("success", `✓ Successfully connected to "${name}"`), + theme.fg("success", `${theme.status.enabled} Successfully connected to "${name}"`), "", ` Server: ${connection.serverInfo.name} v${connection.serverInfo.version}`, ` Tools: ${tools.length}`, @@ -1243,12 +1243,18 @@ export class MCPCommandController { ? theme.fg("muted", "Connecting") : theme.fg("warning", "Not connected yet"); this.#showMessage( - ["", theme.fg("success", `✓ Enabled "${name}"`), "", ` Status: ${status}`, ""].join("\n"), + [ + "", + theme.fg("success", `${theme.status.enabled} Enabled "${name}"`), + "", + ` Status: ${status}`, + "", + ].join("\n"), ); } else { await this.ctx.mcpManager?.disconnectServer(name); await this.ctx.session.refreshMCPTools(this.ctx.mcpManager?.getTools() ?? []); - this.#showMessage(["", theme.fg("success", `✓ Disabled "${name}"`), ""].join("\n")); + this.#showMessage(["", theme.fg("muted", `${theme.status.disabled} Disabled "${name}"`), ""].join("\n")); } return; } @@ -1279,7 +1285,9 @@ export class MCPCommandController { const lines = [ "", - theme.fg("success", `✓ ${enabled ? "Enabled" : "Disabled"} "${name}" (${found.scope} config)`), + enabled + ? theme.fg("success", `${theme.status.enabled} Enabled "${name}" (${found.scope} config)`) + : theme.fg("muted", `${theme.status.disabled} Disabled "${name}" (${found.scope} config)`), ]; if (status) { lines.push(""); @@ -1317,7 +1325,7 @@ export class MCPCommandController { await this.#reloadMCP(); this.#showMessage( - ["", theme.fg("success", `✓ Cleared auth for "${name}" (${found.scope} config)`), ""].join("\n"), + ["", theme.fg("success", `- Cleared auth for "${name}" (${found.scope} config)`), ""].join("\n"), ); } catch (error) { this.ctx.showError(`Failed to clear auth: ${error instanceof Error ? error.message : String(error)}`); @@ -1411,7 +1419,12 @@ export class MCPCommandController { await this.#reloadMCP(); const connectedCount = this.ctx.mcpManager?.getConnectedServers().length ?? 0; this.#showMessage( - ["", theme.fg("success", "✓ MCP reload complete"), ` Connected servers: ${connectedCount}`, ""].join("\n"), + [ + "", + theme.fg("success", `${theme.icon.loop} MCP reload complete`), + ` Connected servers: ${connectedCount}`, + "", + ].join("\n"), ); } catch (error) { this.ctx.showError(`Failed to reload MCP: ${error instanceof Error ? error.message : String(error)}`); @@ -1442,9 +1455,12 @@ export class MCPCommandController { await this.ctx.session.refreshMCPTools(this.ctx.mcpManager.getTools()); const serverTools = this.ctx.mcpManager.getTools().filter(t => t.mcpServerName === name); this.#showMessage( - ["\n", theme.fg("success", `✓ Reconnected to "${name}"`), ` Tools: ${serverTools.length}`, "\n"].join( + [ "\n", - ), + theme.fg("success", `${theme.status.enabled} Reconnected to "${name}"`), + ` Tools: ${serverTools.length}`, + "\n", + ].join("\n"), ); } else { this.ctx.showError(`Failed to reconnect to "${name}". Check server status and logs.`); diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 2d2728db2..5d644aa4a 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -1,6 +1,7 @@ import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; -import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth"; -import type { OAuthProvider } from "@oh-my-pi/pi-ai/utils/oauth/types"; +import { PASTE_CODE_LOGIN_PROVIDERS } from "@oh-my-pi/pi-ai"; +import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; +import type { OAuthProvider } from "@oh-my-pi/pi-ai/oauth/types"; import type { Component, OverlayHandle } from "@oh-my-pi/pi-tui"; import { Input, Loader, Spacer, Text } from "@oh-my-pi/pi-tui"; import { getAgentDbPath, getProjectDir, normalizePathForComparison } from "@oh-my-pi/pi-utils"; @@ -57,14 +58,6 @@ import type { SessionObserverRegistry } from "../session-observer-registry"; import { computeContextBreakdown } from "../utils/context-usage"; import { buildCopyTargets } from "../utils/copy-targets"; -const CALLBACK_SERVER_PROVIDERS = new Set([ - "anthropic", - "openai-codex", - "gitlab-duo", - "google-gemini-cli", - "google-antigravity", -]); - const MANUAL_LOGIN_TIP = "Tip: You can complete pairing with /login ."; export class SelectorController { @@ -928,7 +921,7 @@ export class SelectorController { async #handleOAuthLogin(providerId: string): Promise { this.ctx.showStatus(`Logging in to ${providerId}…`); const manualInput = this.ctx.oauthManualInput; - const useManualInput = CALLBACK_SERVER_PROVIDERS.has(providerId as OAuthProvider); + const useManualInput = PASTE_CODE_LOGIN_PROVIDERS.has(providerId); try { await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, { onAuth: (info: { url: string; instructions?: string }) => { diff --git a/packages/coding-agent/src/modes/controllers/ssh-command-controller.ts b/packages/coding-agent/src/modes/controllers/ssh-command-controller.ts index fd835e19d..6b01508bf 100644 --- a/packages/coding-agent/src/modes/controllers/ssh-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/ssh-command-controller.ts @@ -209,7 +209,7 @@ export class SSHCommandController { const scopeLabel = scope === "user" ? "user" : "project"; const lines = [ "", - theme.fg("success", `✓ Added SSH host "${name}" to ${scopeLabel} config`), + theme.fg("success", `+ Added SSH host "${name}" to ${scopeLabel} config`), "", ` Host: ${host}`, ]; @@ -368,7 +368,7 @@ export class SSHCommandController { await this.ctx.session.refreshSshTool(); this.#showMessage( - ["", theme.fg("success", `✓ Removed SSH host "${name}" from ${scope} config`), ""].join("\n"), + ["", theme.fg("success", `- Removed SSH host "${name}" from ${scope} config`), ""].join("\n"), ); } catch (error) { this.ctx.showError(`Failed to remove host: ${error instanceof Error ? error.message : String(error)}`); diff --git a/packages/coding-agent/src/modes/image-references.ts b/packages/coding-agent/src/modes/image-references.ts index 1d97e73ee..9dae460cf 100644 --- a/packages/coding-agent/src/modes/image-references.ts +++ b/packages/coding-agent/src/modes/image-references.ts @@ -3,30 +3,36 @@ import { logger } from "@oh-my-pi/pi-utils"; import { type BlobPutResult, blobExtensionForImageMimeType } from "../session/blob-store"; import { fileHyperlink } from "../tui/hyperlink"; -const IMAGE_REFERENCE_REGEX = /\[Image #([1-9]\d*)\]/g; +/** Matches `[Image #N]`/`[Image #N, WxH]` and `[Paste #N, +X lines]`/`[Paste #N, Y chars]` tokens. + * Group 1 is the kind (`Image`/`Paste`), group 2 the 1-based index. The optional metadata + * tail (`, …`) is captured loosely (no `]`/newline) so future label tweaks keep matching. */ +export const PLACEHOLDER_REGEX = /\[(Image|Paste) #([1-9]\d*)(?:,[^\]\n]*)?\]/g; type ImageBlobWriter = (data: Buffer, options?: { extension?: string }) => Promise; type ImageBlobWriterSync = (data: Buffer, options?: { extension?: string }) => BlobPutResult; -export interface ImageReferenceRenderers { +export type PlaceholderKind = "image" | "paste"; + +export interface PlaceholderRenderers { renderText: (text: string) => string; - renderReference: (label: string, index: number) => string; + renderReference: (label: string, kind: PlaceholderKind, index: number) => string; } -export function renderImageReferences(text: string, renderers: ImageReferenceRenderers): string { - IMAGE_REFERENCE_REGEX.lastIndex = 0; +export function renderPlaceholders(text: string, renderers: PlaceholderRenderers): string { + PLACEHOLDER_REGEX.lastIndex = 0; let result = ""; let last = 0; let matched = false; for (;;) { - const match = IMAGE_REFERENCE_REGEX.exec(text); + const match = PLACEHOLDER_REGEX.exec(text); if (match === null) break; matched = true; if (match.index > last) { result += renderers.renderText(text.slice(last, match.index)); } - result += renderers.renderReference(match[0], Number(match[1])); + const kind: PlaceholderKind = match[1] === "Paste" ? "paste" : "image"; + result += renderers.renderReference(match[0], kind, Number(match[2])); last = match.index + match[0].length; } diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 266516354..39e03443b 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -2806,8 +2806,8 @@ export class InteractiveMode implements InteractiveModeContext { this.#uiHelpers.updatePendingMessagesDisplay(); } - queueCompactionMessage(text: string, mode: "steer" | "followUp"): void { - this.#uiHelpers.queueCompactionMessage(text, mode); + queueCompactionMessage(text: string, mode: "steer" | "followUp", images?: ImageContent[]): void { + this.#uiHelpers.queueCompactionMessage(text, mode, images); } flushCompactionQueue(options?: { willRetry?: boolean }): Promise { diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 69173a5ad..778cfa649 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -10,7 +10,7 @@ * - Events: AgentSessionEvent objects streamed as they occur * - Extension UI: Extension UI requests are emitted, client responds with extension_ui_response */ -import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth"; +import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; import { $env, readJsonl, Snowflake } from "@oh-my-pi/pi-utils"; import { type ExtensionUIContext, diff --git a/packages/coding-agent/src/modes/setup-wizard/scenes/sign-in.ts b/packages/coding-agent/src/modes/setup-wizard/scenes/sign-in.ts index 7ddc176f4..df054f18c 100644 --- a/packages/coding-agent/src/modes/setup-wizard/scenes/sign-in.ts +++ b/packages/coding-agent/src/modes/setup-wizard/scenes/sign-in.ts @@ -1,20 +1,12 @@ import type { AuthStorage } from "@oh-my-pi/pi-ai"; -import type { OAuthProvider } from "@oh-my-pi/pi-ai/utils/oauth/types"; +import { PASTE_CODE_LOGIN_PROVIDERS } from "@oh-my-pi/pi-ai"; +import type { OAuthProvider } from "@oh-my-pi/pi-ai/oauth/types"; import { Input, matchesKey, wrapTextWithAnsi } from "@oh-my-pi/pi-tui"; import { getAgentDbPath } from "@oh-my-pi/pi-utils"; import { OAuthSelectorComponent } from "../../components/oauth-selector"; import { theme } from "../../theme/theme"; import type { SetupSceneHost, SetupTab } from "./types"; -/** Providers whose OAuth flow needs a pasted code/redirect URL rather than a callback server. */ -const CALLBACK_SERVER_PROVIDERS: Partial> = { - anthropic: true, - "openai-codex": true, - "gitlab-duo": true, - "google-gemini-cli": true, - "google-antigravity": true, -}; - function loginUrlLink(url: string): string { return `\x1b]8;;${url}\x07Open login URL\x1b]8;;\x07`; } @@ -119,7 +111,7 @@ export class SignInTab implements SetupTab { async #login(providerId: string): Promise { if (this.#loggingInProvider || this.#disposed) return; - const useManualInput = CALLBACK_SERVER_PROVIDERS[providerId as OAuthProvider] === true; + const useManualInput = PASTE_CODE_LOGIN_PROVIDERS.has(providerId); this.#selector.stopValidation(); this.#loggingInProvider = providerId; this.#statusLines = [theme.fg("dim", "Starting OAuth flow…")]; diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index ed0d46d24..f9ebba809 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -43,6 +43,7 @@ export type SymbolKey = | "status.running" | "status.shadowed" | "status.aborted" + | "status.done" // Navigation | "nav.cursor" | "nav.selected" @@ -197,7 +198,29 @@ export type SymbolKey = | "tab.tools" | "tab.memory" | "tab.tasks" - | "tab.providers"; + | "tab.providers" + // Tool identity icons + | "tool.write" + | "tool.edit" + | "tool.bash" + | "tool.ssh" + | "tool.lsp" + | "tool.gh" + | "tool.webSearch" + | "tool.exa" + | "tool.browser" + | "tool.eval" + | "tool.debug" + | "tool.mcp" + | "tool.job" + | "tool.task" + | "tool.todo" + | "tool.memory" + | "tool.ask" + | "tool.resolve" + | "tool.review" + | "tool.inspectImage" + | "tool.goal"; type SymbolMap = Record; @@ -213,6 +236,7 @@ const UNICODE_SYMBOLS: SymbolMap = { "status.running": "⟳", "status.shadowed": "◌", "status.aborted": "⏹", + "status.done": "•", // Navigation "nav.cursor": "❯", "nav.selected": "➤", @@ -368,6 +392,28 @@ const UNICODE_SYMBOLS: SymbolMap = { "tab.memory": "🧠", "tab.tasks": "📦", "tab.providers": "🌐", + // Tool identity icons (per-tool signature glyph on the success header) + "tool.write": "✎", + "tool.edit": "✎", + "tool.bash": "❯", + "tool.ssh": "⇄", + "tool.lsp": "💡", + "tool.gh": "⎇", + "tool.webSearch": "⌕", + "tool.exa": "🔭", + "tool.browser": "🌐", + "tool.eval": "▶", + "tool.debug": "🐞", + "tool.mcp": "🔌", + "tool.job": "⚙", + "tool.task": "⇶", + "tool.todo": "☑", + "tool.memory": "🧠", + "tool.ask": "?", + "tool.resolve": "✓", + "tool.review": "◉", + "tool.inspectImage": "🖼", + "tool.goal": "◎", }; const NERD_SYMBOLS: SymbolMap = { @@ -392,6 +438,8 @@ const NERD_SYMBOLS: SymbolMap = { "status.shadowed": "◐", // pick:  | alt:   "status.aborted": "\uf04d", + // pick: • | alt: ● · + "status.done": "•", // Navigation // pick:  | alt:   "nav.cursor": "\uf054", @@ -638,6 +686,28 @@ const NERD_SYMBOLS: SymbolMap = { "tab.memory": "󰧑", "tab.tasks": "󰐱", "tab.providers": "󰖟", + // Tool identity icons (per-tool signature glyph on the success header) + "tool.write": "\uEA7F", + "tool.edit": "\uEA73", + "tool.bash": "\uEBCA", + "tool.ssh": "\uEB3A", + "tool.lsp": "\uEA61", + "tool.gh": "\uEA84", + "tool.webSearch": "\uEB01", + "tool.exa": "\uEB68", + "tool.browser": "\uEAAE", + "tool.eval": "\uEBAF", + "tool.debug": "\uEAD8", + "tool.mcp": "\uEB2D", + "tool.job": "\uEBA2", + "tool.task": "\uEA7E", + "tool.todo": "\uEAB3", + "tool.memory": "\uEACE", + "tool.ask": "\uEAC7", + "tool.resolve": "\uEBB1", + "tool.review": "\uEA70", + "tool.inspectImage": "\uEAEA", + "tool.goal": "\uEBF8", }; const ASCII_SYMBOLS: SymbolMap = { @@ -652,6 +722,7 @@ const ASCII_SYMBOLS: SymbolMap = { "status.running": "[~]", "status.shadowed": "[/]", "status.aborted": "[-]", + "status.done": "*", // Navigation "nav.cursor": ">", "nav.selected": "->", @@ -805,6 +876,28 @@ const ASCII_SYMBOLS: SymbolMap = { "tab.memory": "[Y]", "tab.tasks": "[K]", "tab.providers": "[P]", + // Tool identity icons (per-tool signature glyph on the success header) + "tool.write": "+f", + "tool.edit": "~", + "tool.bash": "$", + "tool.ssh": "ssh", + "tool.lsp": "lsp", + "tool.gh": "gh", + "tool.webSearch": "web", + "tool.exa": "exa", + "tool.browser": "[w]", + "tool.eval": ">_", + "tool.debug": "dbg", + "tool.mcp": "<>", + "tool.job": "job", + "tool.task": ">>>", + "tool.todo": "[x]", + "tool.memory": "mem", + "tool.ask": "[?]", + "tool.resolve": "[v]", + "tool.review": "rev", + "tool.inspectImage": "[i]", + "tool.goal": "(o)", }; const SYMBOL_PRESETS: Record = { @@ -1485,6 +1578,7 @@ export class Theme { running: this.#symbols["status.running"], shadowed: this.#symbols["status.shadowed"], aborted: this.#symbols["status.aborted"], + done: this.#symbols["status.done"], }; } diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index a30aa242f..c1efbcda6 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -35,6 +35,7 @@ import type { Theme } from "./theme/theme"; export type CompactionQueuedMessage = { text: string; mode: "steer" | "followUp"; + images?: ImageContent[]; }; export type SubmittedUserInput = { @@ -180,7 +181,7 @@ export interface InteractiveModeContext { showNewVersionNotification(newVersion: string): void; clearEditor(): void; updatePendingMessagesDisplay(): void; - queueCompactionMessage(text: string, mode: "steer" | "followUp"): void; + queueCompactionMessage(text: string, mode: "steer" | "followUp", images?: ImageContent[]): void; flushCompactionQueue(options?: { willRetry?: boolean }): Promise; flushPendingBashComponents(): void; flushPendingModelSwitch(): Promise; diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index ce35dc02c..3136296f8 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -161,7 +161,7 @@ export class UiHelpers { const typeLabel = job.type ? `[${job.type}]` : "[job]"; const duration = typeof job.durationMs === "number" ? formatDuration(job.durationMs) : undefined; const line = [ - theme.fg("success", `${theme.status.success} Background job completed`), + theme.fg("success", `${theme.status.done} Background job completed`), theme.fg("dim", typeLabel), theme.fg("accent", jobId), duration ? theme.fg("dim", `(${duration})`) : undefined, @@ -630,12 +630,18 @@ export class UiHelpers { } } - queueCompactionMessage(text: string, mode: "steer" | "followUp"): void { - this.ctx.compactionQueuedMessages.push({ text, mode } as CompactionQueuedMessage); + queueCompactionMessage(text: string, mode: "steer" | "followUp", images?: ImageContent[]): void { + const queuedImages = images && images.length > 0 ? images : undefined; + this.ctx.compactionQueuedMessages.push({ text, mode, images: queuedImages } as CompactionQueuedMessage); this.ctx.editor.addToHistory(text); this.ctx.editor.setText(""); + this.ctx.editor.imageLinks = undefined; + this.ctx.pendingImages = []; + this.ctx.pendingImageLinks = []; this.ctx.updatePendingMessagesDisplay(); - this.ctx.showStatus("Queued message for after compaction"); + this.ctx.showStatus( + queuedImages ? "Queued message with image for after compaction" : "Queued message for after compaction", + ); } async #deliverQueuedMessage(message: CompactionQueuedMessage): Promise { @@ -644,7 +650,9 @@ export class UiHelpers { return; } await this.ctx.withLocalSubmission(message.text, () => - message.mode === "followUp" ? this.ctx.session.followUp(message.text) : this.ctx.session.steer(message.text), + message.mode === "followUp" + ? this.ctx.session.followUp(message.text, message.images) + : this.ctx.session.steer(message.text, message.images), ); } @@ -738,6 +746,7 @@ export class UiHelpers { const promptPromise = this.ctx.session .prompt(firstPrompt.text, { streamingBehavior: firstPrompt.mode === "followUp" ? "followUp" : "steer", + images: firstPrompt.images, }) .catch((error: unknown) => { disposeFirstPrompt(); diff --git a/packages/coding-agent/src/prompts/tools/bash.md b/packages/coding-agent/src/prompts/tools/bash.md index d45ad446e..776f1367c 100644 --- a/packages/coding-agent/src/prompts/tools/bash.md +++ b/packages/coding-agent/src/prompts/tools/bash.md @@ -29,7 +29,7 @@ Executes bash command in shell session for terminal operations like git, bun, ca - `timeout` (seconds) caps the **wall-clock duration** of the command. When it elapses the process is killed and the call returns with a timeout annotation. Range: `1`–`3600`s; default `300`s (see `clampTimeout("bash", …)` in `tool-timeouts.ts`). - `async: true` only defers **reporting** of the result — it does NOT disable, extend, or detach the timeout. A daemon started with `async: true` is still killed when `timeout` elapses, regardless of how long the agent waits before reading the result. -- For long-running daemons (dev servers, watchers): either pass an explicit large `timeout` (up to `3600`), or fully detach the process from this shell using `nohup … &` / `setsid … &` / `disown` so it survives independent of the bash call's lifecycle. +- For long-running daemons (dev servers, watchers): pass an explicit large `timeout` (up to `3600`). The shell session persists across calls, so a backgrounded job (`cmd &`) keeps running between bash calls on its own. {{/if}} {{#if autoBackgroundEnabled}} diff --git a/packages/coding-agent/src/prompts/tools/eval.md b/packages/coding-agent/src/prompts/tools/eval.md index 35d216690..cbd818631 100644 --- a/packages/coding-agent/src/prompts/tools/eval.md +++ b/packages/coding-agent/src/prompts/tools/eval.md @@ -8,7 +8,7 @@ Cell fields: - `language` — {{#if py}}`"py"` for the IPython kernel{{/if}}{{#ifAll py js}}, {{/ifAll}}{{#if js}}`"js"` for the persistent JavaScript VM{{/if}}. - `code` — cell body, verbatim. Newlines, quotes, and indentation are JSON-encoded; no fences, no headers. - `title` (optional) — short label shown in the transcript (e.g. `"imports"`, `"load config"`). -- `timeout` (optional) — per-cell wall-clock budget in seconds (1-600). Default 30. It bounds the cell's **own** work, but is paused while an `agent()`/`parallel()`/`completion()` call is in flight — so a long fanout or a slow completion runs to completion, while the cell itself is still bounded. Compute, `print`/stdout, `log()`/`phase()`, and ordinary tool calls all count against the budget; raise `timeout` for a cell that does heavy local work or long non-agent tool calls. +- `timeout` (optional) — per-cell wall-clock budget in seconds (1-3600). Default 30. It bounds the cell's **own** work, but is paused while an `agent()`/`parallel()`/`completion()` call is in flight — so a long fanout or a slow completion runs to completion, while the cell itself is still bounded. Compute, `print`/stdout, `log()`/`phase()`, and ordinary tool calls all count against the budget; raise `timeout` for a cell that does heavy local work or long non-agent tool calls. - `reset` (optional) — wipe this cell's language kernel before running.{{#ifAll py js}} Reset is per-language: a `py` cell's reset does not touch the JavaScript VM and vice versa.{{/ifAll}} **Work incrementally:** @@ -29,11 +29,11 @@ display(value) → None print(value, ...) → None Print to the cell's text output. read(path, offset?=1, limit?=None) → str - Read file contents as text. offset/limit are 1-indexed line bounds. + Read file contents as text. offset/limit are 1-indexed line bounds. Accepts `local://…` (resolved to the session-local root, same place `read local://…` reads). write(path, content) → str - Write content to a file (creates parent directories). Returns the resolved path. + Write content to a file (creates parent directories). Returns the resolved path. Accepts `local://…` to persist artifacts across turns / share with subagents. append(path, content) → str - Append content to a file. Returns the resolved path. + Append content to a file. Returns the resolved path. Accepts `local://…`. tree(path?=".", max_depth?=3, show_hidden?=False) → str Render a directory tree. diff(a, b) → str diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 6e9f8caad..75fdb3966 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -154,6 +154,7 @@ import { EditTool, EvalTool, FindTool, + filterInitialToolsForDiscoveryAll, getSearchTools, HIDDEN_TOOLS, isImageProviderPreference, @@ -1427,7 +1428,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const getArtifactsDir = () => sessionManager.getArtifactsDir(); if (!options.parentTaskPrefix) { setActiveSkills(skills); - setActiveRules([...rulebookRules, ...alwaysApplyRules]); + // Include TTSR rules so `rule://` can resolve them too. They are + // registered with the manager and bucketed out before rulebook/always, + // so without this a TTSR-only rule (e.g. a triggered builtin) is not + // addressable and `rule://` reports "Available: none". + setActiveRules([...rulebookRules, ...alwaysApplyRules, ...ttsrManager.getRules()]); if (asyncJobManager) AsyncJobManager.setInstance(asyncJobManager); } const localProtocolOptions = options.localProtocolOptions ?? { @@ -1570,6 +1575,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } extensionsResult.runtime.pendingProviderRegistrations = []; } + // Discover runtime (extension) provider catalogs now that they are + // registered. The startup refreshInBackground() ran before extensions + // loaded, so dynamic extension providers are only discovered here. Runs in + // the background (cache-aware) so startup is never blocked on the fetch; the + // model list re-renders when the catalog arrives, like other dynamic providers. + void modelRegistry.refreshRuntimeProviders().catch(error => { + logger.warn("runtime provider discovery failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); // Retry session-model candidates now that extension providers are // registered. The initial restore runs before extensions load, so a role @@ -1951,19 +1966,21 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // from the initial set unless they were explicitly requested or restored from persistence. // The model finds them via search_tool_bm25 and activates them on demand. if (effectiveDiscoveryMode === "all") { - const essentialBuiltinNames = new Set(computeEssentialBuiltinNames(settings)); - const explicitlyRequestedToolNames = new Set(options.toolNames?.map(name => name.toLowerCase()) ?? []); - // Back-compat: persisted activations live under selectedMCPToolNames today (built-in - // activation persistence is a follow-up). MCP names won't collide with built-in names. - const restoredDiscoveredNames = new Set(existingSession.selectedMCPToolNames); - initialToolNames = initialToolNames.filter(name => { - const tool = toolRegistry.get(name); - if (!tool?.loadMode) return true; // not a built-in — leave MCP/custom/extension to existing logic - if (tool.loadMode === "essential") return true; - if (essentialBuiltinNames.has(name)) return true; - if (explicitlyRequestedToolNames.has(name)) return true; - if (restoredDiscoveredNames.has(name)) return true; - return false; + // Tools a forced tool_choice will target must stay active, or the named + // choice references a tool absent from the request (provider 400). Eager + // todos force a named `todo` choice on the first turn. + const forceActive = new Set(); + if (settings.get("todo.eager") && settings.get("todo.enabled") && toolRegistry.has("todo")) { + forceActive.add("todo"); + } + initialToolNames = filterInitialToolsForDiscoveryAll(initialToolNames, { + loadModeOf: name => toolRegistry.get(name)?.loadMode, + essentialNames: new Set(computeEssentialBuiltinNames(settings)), + explicitlyRequested: new Set(options.toolNames?.map(name => name.toLowerCase()) ?? []), + // Back-compat: persisted activations live under selectedMCPToolNames today (built-in + // activation persistence is a follow-up). MCP names won't collide with built-in names. + restored: new Set(existingSession.selectedMCPToolNames), + forceActive, }); } diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 2a3204943..6a799fcca 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -13,7 +13,6 @@ * Modes use this class and add their own I/O layer on top. */ -import * as crypto from "node:crypto"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -33,6 +32,7 @@ import { resolveTelemetry, ThinkingLevel, } from "@oh-my-pi/pi-agent-core"; + import { AGGRESSIVE_SHAKE_CONFIG, AUTO_HANDOFF_THRESHOLD_FOCUS, @@ -77,6 +77,7 @@ import type { import { calculateRateLimitBackoffMs, clearAnthropicFastModeFallback, + deriveClaudeDeviceId, Effort, getSupportedEfforts, isContextOverflow, @@ -215,6 +216,7 @@ import { parseCommandArgs } from "../utils/command-args"; import { type EditMode, resolveEditMode } from "../utils/edit-mode"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions"; +import { normalizeModelContextImages } from "../utils/image-loading"; import { buildNamedToolChoice } from "../utils/tool-choice"; import type { AuthStorage } from "./auth-storage"; import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge"; @@ -531,15 +533,6 @@ function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): strin } const IRC_REPLY_MAX_BYTES = 4096; -export const ANTHROPIC_TOOL_CALL_BATCH_CAP = 4; -const CLAUDE_OPUS_4_8_MODEL_ID = /(?:^|[./_-])claude-opus-4[.-]8\b/i; - -export function resolveToolCallBatchCapForModel(model: Model | undefined): number | undefined { - if (!model) return undefined; - return model.provider === "anthropic" && CLAUDE_OPUS_4_8_MODEL_ID.test(model.id) - ? ANTHROPIC_TOOL_CALL_BATCH_CAP - : undefined; -} /** * Collapse degenerate IRC ephemeral replies before they hit the relay. @@ -613,14 +606,10 @@ function buildSessionMetadata( const accountUuid = authStorage?.getOAuthAccountId("anthropic", sessionId); if (typeof accountUuid === "string" && accountUuid.length > 0) { userId.account_uuid = accountUuid; - // Claude Code's `device_id` is a stable 64-hex install identifier. Use - // omp's persistent install id as the root instead of deriving it from - // `account_uuid`: logging into a different Claude account on the same - // install should not make the device look new. - userId.device_id = crypto - .createHash("sha256") - .update(`omp-claude-device-id-v1:${getInstallId()}`) - .digest("hex"); + // Claude Code's `device_id` is a stable 64-hex account-scoped install + // identifier. Include both omp's persistent install id and the Claude + // account UUID so two accounts on the same install do not share a device. + userId.device_id = deriveClaudeDeviceId(getInstallId(), accountUuid); } } return { user_id: JSON.stringify(userId) }; @@ -1102,10 +1091,6 @@ export class AgentSession { this.#flushPendingAgentEnd(); } - #syncToolCallBatchCap(model: Model | undefined = this.model): void { - this.agent.maxToolCallsPerTurn = resolveToolCallBatchCapForModel(model); - } - #flushPendingAgentEnd(): void { const pending = this.#pendingAgentEndEmit; if (!pending) return; @@ -1224,7 +1209,6 @@ export class AgentSession { this.#agentId = config.agentId; this.#agentRegistry = config.agentRegistry; this.#providerSessionId = config.providerSessionId; - this.#syncToolCallBatchCap(); this.agent.setAssistantMessageEventInterceptor((message, assistantMessageEvent) => { const event: AgentEvent = { type: "message_update", @@ -1687,89 +1671,18 @@ export class AgentSession { } if (matchContext && "delta" in assistantEvent) { + const targetMessageTimestamp = event.message.role === "assistant" ? event.message.timestamp : undefined; const matches = this.#checkTtsrStream(assistantEvent.delta, matchContext, streamingToolCall); - if (matches.length > 0) { - // Decide first: a non-interrupting tool-source match attaches to the - // specific tool call's result instead of driving a loop-wide follow-up. - const shouldInterrupt = this.#shouldInterruptForTtsrMatch(matches, matchContext); - const perToolId = shouldInterrupt ? undefined : this.#extractTtsrToolCallId(matchContext); - if (perToolId) { - this.#addPerToolTtsrInjections(perToolId, matches); - this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {}); - } else { - // Queue rules for injection; mark as injected only after successful enqueue. - this.#addPendingTtsrInjections(matches); - - if (shouldInterrupt) { - // Abort the stream immediately — do not gate on extension callbacks - this.#ttsrAbortPending = true; - this.#ensureTtsrResumePromise(); - this.agent.abort(this.#formatTtsrAbortReason(matches)); - // Notify extensions (fire-and-forget, does not block abort) - this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {}); - // Schedule retry after a short delay - const retryToken = ++this.#ttsrRetryToken; - const generation = this.#promptGeneration; - const targetMessageTimestamp = - event.message.role === "assistant" ? event.message.timestamp : undefined; - this.#schedulePostPromptTask( - async () => { - if (this.#ttsrRetryToken !== retryToken) { - this.#resolveTtsrResume(); - return; - } - - const targetAssistantIndex = this.#findTtsrAssistantIndex(targetMessageTimestamp); - if ( - !this.#ttsrAbortPending || - this.#promptGeneration !== generation || - targetAssistantIndex === -1 - ) { - this.#ttsrAbortPending = false; - this.#pendingTtsrInjections = []; - this.#perToolTtsrInjections.clear(); - this.#resolveTtsrResume(); - return; - } - this.#ttsrAbortPending = false; - this.#perToolTtsrInjections.clear(); - const ttsrSettings = this.#ttsrManager?.getSettings(); - if (ttsrSettings?.contextMode === "discard") { - // Remove the partial/aborted assistant turn from agent state - this.agent.replaceMessages(this.agent.state.messages.slice(0, targetAssistantIndex)); - } - // Inject TTSR rules as system reminder before retry - const injection = this.#getTtsrInjectionContent(); - if (injection) { - const details = { rules: injection.rules.map(rule => rule.name) }; - this.agent.appendMessage({ - role: "custom", - customType: "ttsr-injection", - content: injection.content, - display: false, - details, - attribution: "agent", - timestamp: Date.now(), - }); - this.sessionManager.appendCustomMessageEntry( - "ttsr-injection", - injection.content, - false, - details, - "agent", - ); - this.#markTtsrInjected(details.rules); - } - try { - await this.agent.continue(); - } catch { - this.#resolveTtsrResume(); - } - }, - { delayMs: 50 }, - ); - return; - } + if (matches.length > 0 && this.#handleTtsrMatches(matches, matchContext, targetMessageTimestamp)) { + return; + } + // ast-grep `astCondition` rules match against the reconstructed edit/write + // snapshot, which only exists for tool argument streams. The native worker + // call is async, so this path is awaited and self-throttled by the manager. + if (matchContext.source === "tool" && this.#ttsrManager?.hasAstRules()) { + const astMatches = await this.#checkTtsrAstStream(matchContext, streamingToolCall); + if (astMatches.length > 0 && this.#handleTtsrMatches(astMatches, matchContext, targetMessageTimestamp)) { + return; } } } @@ -2441,19 +2354,134 @@ export class AgentSession { if (!manager) { return []; } - if (toolCall) { - const tools = this.agent.state.tools; - const tool = - tools.find(t => t.name === toolCall.name) ?? - tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name); - const digest = tool?.matcherDigest?.(toolCall.arguments ?? {}); - if (digest !== undefined) { - return manager.checkSnapshot(digest, matchContext); - } + const digest = this.#resolveTtsrMatcherDigest(toolCall); + if (digest !== undefined) { + return manager.checkSnapshot(digest, matchContext); } return manager.checkDelta(delta, matchContext); } + /** Reconstruct the tool's normalized source snapshot via its `matcherDigest`, if any. */ + #resolveTtsrMatcherDigest(toolCall: ToolCall | undefined): string | undefined { + if (!toolCall) { + return undefined; + } + const tools = this.agent.state.tools; + const tool = + tools.find(t => t.name === toolCall.name) ?? + tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name); + return tool?.matcherDigest?.(toolCall.arguments ?? {}); + } + + /** + * Match ast-grep `astCondition` rules against the reconstructed tool snapshot. + * + * Only edit/write tool streams expose a `matcherDigest`, which is the real source + * the call introduces; AST matching needs that (and a language inferred from the + * path argument), so non-digest streams never produce AST matches. + */ + async #checkTtsrAstStream(matchContext: TtsrMatchContext, toolCall: ToolCall | undefined): Promise { + const manager = this.#ttsrManager; + if (!manager) { + return []; + } + const digest = this.#resolveTtsrMatcherDigest(toolCall); + if (digest === undefined) { + return []; + } + return manager.checkAstSnapshot(digest, matchContext); + } + + /** + * Route TTSR matches to either a per-tool injection or a stream-interrupting + * retry. Returns true when the stream was aborted and the caller should stop + * processing this event. + */ + #handleTtsrMatches( + matches: Rule[], + matchContext: TtsrMatchContext, + targetMessageTimestamp: number | undefined, + ): boolean { + // Decide first: a non-interrupting tool-source match attaches to the + // specific tool call's result instead of driving a loop-wide follow-up. + const shouldInterrupt = this.#shouldInterruptForTtsrMatch(matches, matchContext); + const perToolId = shouldInterrupt ? undefined : this.#extractTtsrToolCallId(matchContext); + if (perToolId) { + this.#addPerToolTtsrInjections(perToolId, matches); + this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {}); + return false; + } + + // Queue rules for injection; mark as injected only after successful enqueue. + this.#addPendingTtsrInjections(matches); + if (!shouldInterrupt) { + return false; + } + + // Abort the stream immediately — do not gate on extension callbacks + this.#ttsrAbortPending = true; + this.#ensureTtsrResumePromise(); + this.agent.abort(this.#formatTtsrAbortReason(matches)); + // Notify extensions (fire-and-forget, does not block abort) + this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {}); + // Schedule retry after a short delay + const retryToken = ++this.#ttsrRetryToken; + const generation = this.#promptGeneration; + this.#schedulePostPromptTask( + async () => { + if (this.#ttsrRetryToken !== retryToken) { + this.#resolveTtsrResume(); + return; + } + + const targetAssistantIndex = this.#findTtsrAssistantIndex(targetMessageTimestamp); + if (!this.#ttsrAbortPending || this.#promptGeneration !== generation || targetAssistantIndex === -1) { + this.#ttsrAbortPending = false; + this.#pendingTtsrInjections = []; + this.#perToolTtsrInjections.clear(); + this.#resolveTtsrResume(); + return; + } + this.#ttsrAbortPending = false; + this.#perToolTtsrInjections.clear(); + const ttsrSettings = this.#ttsrManager?.getSettings(); + if (ttsrSettings?.contextMode === "discard") { + // Remove the partial/aborted assistant turn from agent state + this.agent.replaceMessages(this.agent.state.messages.slice(0, targetAssistantIndex)); + } + // Inject TTSR rules as system reminder before retry + const injection = this.#getTtsrInjectionContent(); + if (injection) { + const details = { rules: injection.rules.map(rule => rule.name) }; + this.agent.appendMessage({ + role: "custom", + customType: "ttsr-injection", + content: injection.content, + display: false, + details, + attribution: "agent", + timestamp: Date.now(), + }); + this.sessionManager.appendCustomMessageEntry( + "ttsr-injection", + injection.content, + false, + details, + "agent", + ); + this.#markTtsrInjected(details.rules); + } + try { + await this.agent.continue(); + } catch { + this.#resolveTtsrResume(); + } + }, + { delayMs: 50 }, + ); + return true; + } + /** Extract path-like arguments from tool call payload for TTSR glob matching. */ #extractTtsrFilePathsFromArgs(args: unknown): string[] | undefined { if (!args || typeof args !== "object" || Array.isArray(args)) { @@ -2992,10 +3020,10 @@ export class AgentSession { * `metadata.user_id` shaped like real Claude Code's `getAPIMetadata` output: * `{ session_id, account_uuid, device_id }`. `account_uuid` is included only * when an Anthropic OAuth credential with a known account UUID is loaded; - * `device_id` is derived from the persistent omp install id. Resolving live - * keeps the value in sync with auth-state changes (login/logout, token - * refresh that surfaces a new account uuid) without needing to re-call - * `#syncAgentSessionId()` on every such event. + * `device_id` is derived from both the persistent omp install id and that + * account UUID. Resolving live keeps the value in sync with auth-state changes + * (login/logout, token refresh that surfaces a new account UUID) without + * needing to re-call `#syncAgentSessionId()` on every such event. */ #syncAgentSessionId(sessionId?: string): void { const sid = this.#activeProviderSessionId(sessionId); @@ -4286,6 +4314,27 @@ export class AgentSession { }; } + async #normalizeMessageContentImages( + content: string | (TextContent | ImageContent)[], + ): Promise { + if (typeof content === "string") return content; + const images = content.filter((part): part is ImageContent => part.type === "image"); + if (images.length === 0) return content; + const normalizedImages = await normalizeModelContextImages(images); + if (!normalizedImages) return content; + let imageIndex = 0; + return content.map(part => (part.type === "image" ? normalizedImages[imageIndex++]! : part)); + } + + async #normalizeAgentMessageImages(message: T): Promise { + if (!("content" in message)) return message; + const content = message.content; + if (typeof content !== "string" && !Array.isArray(content)) return message; + const normalized = await this.#normalizeMessageContentImages(content as string | (TextContent | ImageContent)[]); + if (normalized === content) return message; + return { ...message, content: normalized } as T; + } + /** * Send a prompt to the agent. * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming @@ -4385,10 +4434,11 @@ export class AgentSession { const hasPendingUserDirective = this.#toolChoiceQueue.inspect().includes("user-force"); const eagerTodoPrelude = !options?.synthetic && !hasPendingUserDirective ? this.#createEagerTodoPrelude(expandedText) : undefined; + const normalizedImages = await normalizeModelContextImages(options?.images); const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }]; - if (options?.images) { - userContent.push(...options.images); + if (normalizedImages) { + userContent.push(...normalizedImages); } const promptAttribution = options?.attribution ?? (options?.synthetic ? "agent" : "user"); @@ -4405,6 +4455,7 @@ export class AgentSession { try { await this.#promptWithMessage(message, expandedText, { ...options, + images: normalizedImages, prependMessages: eagerTodoPrelude ? [eagerTodoPrelude.message] : undefined, appendMessages: keywordNotices.length > 0 ? keywordNotices : undefined, }); @@ -4547,7 +4598,9 @@ export class AgentSession { useHashLines: resolveFileDisplayMode(this).hashLines, snapshotStore: getFileSnapshotStore(this), }); - messages.push(...fileMentionMessages); + for (const fileMentionMessage of fileMentionMessages) { + messages.push(await this.#normalizeAgentMessageImages(fileMentionMessage)); + } } const beforeAgentStartSystemPrompt = await this.#buildSystemPromptForAgentStart(expandedText); @@ -4563,15 +4616,18 @@ export class AgentSession { const promptAttribution: "user" | "agent" | undefined = "attribution" in message ? message.attribution : undefined; for (const msg of result.messages) { - messages.push({ - role: "custom", - customType: msg.customType, - content: msg.content, - display: msg.display, - details: msg.details, - attribution: msg.attribution ?? promptAttribution ?? (message.role === "user" ? "user" : "agent"), - timestamp: Date.now(), - }); + messages.push( + await this.#normalizeAgentMessageImages({ + role: "custom", + customType: msg.customType, + content: msg.content, + display: msg.display, + details: msg.details, + attribution: + msg.attribution ?? promptAttribution ?? (message.role === "user" ? "user" : "agent"), + timestamp: Date.now(), + }), + ); } } @@ -4779,11 +4835,12 @@ export class AgentSession { * Internal: Queue a steering message (already expanded, no extension command check). */ async #queueSteer(text: string, images?: ImageContent[]): Promise { + const normalizedImages = await normalizeModelContextImages(images); const displayText = text || (images && images.length > 0 ? "[Image]" : ""); this.#steeringMessages.push({ text: displayText }); const content: (TextContent | ImageContent)[] = [{ type: "text", text }]; - if (images && images.length > 0) { - content.push(...images); + if (normalizedImages && normalizedImages.length > 0) { + content.push(...normalizedImages); } this.agent.steer({ role: "user", @@ -4798,11 +4855,12 @@ export class AgentSession { * Internal: Queue a follow-up message (already expanded, no extension command check). */ async #queueFollowUp(text: string, images?: ImageContent[]): Promise { + const normalizedImages = await normalizeModelContextImages(images); const displayText = text || (images && images.length > 0 ? "[Image]" : ""); this.#followUpMessages.push({ text: displayText }); const content: (TextContent | ImageContent)[] = [{ type: "text", text }]; - if (images && images.length > 0) { - content.push(...images); + if (normalizedImages && normalizedImages.length > 0) { + content.push(...normalizedImages); } this.agent.followUp({ role: "user", @@ -4946,16 +5004,17 @@ export class AgentSession { attribution: message.attribution ?? "agent", timestamp: Date.now(), }; + const normalizedAppMessage = await this.#normalizeAgentMessageImages(appMessage); if (this.isStreaming) { if (options?.deliverAs === "nextTurn") { - this.#queueHiddenNextTurnMessage(appMessage, options?.triggerTurn ?? false); + this.#queueHiddenNextTurnMessage(normalizedAppMessage, options?.triggerTurn ?? false); return; } if (options?.deliverAs === "followUp") { - this.agent.followUp(appMessage); + this.agent.followUp(normalizedAppMessage); } else { - this.agent.steer(appMessage); + this.agent.steer(normalizedAppMessage); } return; } @@ -4963,16 +5022,16 @@ export class AgentSession { if (options?.deliverAs === "nextTurn") { if (options?.triggerTurn) { if (this.#clientBridge?.deferAgentInitiatedTurns && !this.#allowAcpAgentInitiatedTurns) { - this.#queueHiddenNextTurnMessage(appMessage, false); + this.#queueHiddenNextTurnMessage(normalizedAppMessage, false); return; } - await this.agent.prompt(appMessage); + await this.agent.prompt(normalizedAppMessage); return; } - this.agent.appendMessage(appMessage); + this.agent.appendMessage(normalizedAppMessage); this.sessionManager.appendCustomMessageEntry( - message.customType, - message.content, + normalizedAppMessage.customType, + normalizedAppMessage.content, message.display, message.details, message.attribution ?? "agent", @@ -4982,17 +5041,17 @@ export class AgentSession { if (options?.triggerTurn) { if (this.#clientBridge?.deferAgentInitiatedTurns && !this.#allowAcpAgentInitiatedTurns) { - this.#queueHiddenNextTurnMessage(appMessage, false); + this.#queueHiddenNextTurnMessage(normalizedAppMessage, false); return; } - await this.agent.prompt(appMessage); + await this.agent.prompt(normalizedAppMessage); return; } - this.agent.appendMessage(appMessage); + this.agent.appendMessage(normalizedAppMessage); this.sessionManager.appendCustomMessageEntry( - message.customType, - message.content, + normalizedAppMessage.customType, + normalizedAppMessage.content, message.display, message.details, message.attribution ?? "agent", @@ -6749,9 +6808,13 @@ export class AgentSession { return undefined; } - if (!this.#toolRegistry.has("todo")) { - logger.warn("Eager todo enforcement skipped because todo is unavailable", { - activeToolNames: this.agent.state.tools.map(tool => tool.name), + // Must check the active tool set, not just the registry: tool discovery + // (tools.discoveryMode === "all") can register `todo` while hiding it from + // the exposed tools. Forcing a named tool_choice for an inactive tool makes + // the provider reject the request (HTTP 400). + if (!this.getActiveToolNames().includes("todo")) { + logger.warn("Eager todo enforcement skipped because todo is not active", { + activeToolNames: this.getActiveToolNames(), }); return undefined; } @@ -6913,7 +6976,6 @@ export class AgentSession { this.#closeProviderSessionsForModelSwitch(currentModel, model); } this.agent.setModel(model); - this.#syncToolCallBatchCap(model); // Re-evaluate append-only context mode — provider or setting may have changed this.#syncAppendOnlyContext(model); @@ -9109,7 +9171,6 @@ export class AgentSession { this.#setModelWithProviderSessionReset(match); } else { this.agent.setModel(match); - this.#syncToolCallBatchCap(match); } } } @@ -9192,9 +9253,6 @@ export class AgentSession { this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration; if (previousModel) { this.agent.setModel(previousModel); - this.#syncToolCallBatchCap(previousModel); - } else { - this.#syncToolCallBatchCap(undefined); } this.#thinkingLevel = previousThinkingLevel; this.#autoThinking = previousAutoThinking; diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 82cff8bab..1d42c9f57 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -3558,7 +3558,7 @@ export class SessionManager { } const relocated = sourceCwdGone && (mostRecent === null || (mostRecentIsBreadcrumb && !hasCurrentCwdSession)); if (relocated) { - process.stderr.write(`Re-rooting moved session from ${resolvedBreadcrumbCwd} to ${resolvedCwd}.\n`); + logger.info("Re-rooting moved session", { from: resolvedBreadcrumbCwd, to: resolvedCwd }); const manager = await SessionManager.open(breadcrumb.sessionFile, undefined, storage); await manager.moveTo(cwd, sessionDir); return manager; diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index ea564bde5..8db27c358 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth"; +import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; import { Snowflake, setProjectDir } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; import type { SettingPath, SettingValue } from "../config/settings"; diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index c0d935a51..21dff32a2 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -10,6 +10,7 @@ import { contextFileCapability } from "./capability/context-file"; import { systemPromptCapability } from "./capability/system-prompt"; import type { SkillsSettings } from "./config/settings"; import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile } from "./discovery"; +import { expandAtImports } from "./discovery/at-imports"; import { loadSkills, type Skill } from "./extensibility/skills"; import { hasObsidian } from "./internal-urls/vault-protocol"; import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" }; @@ -254,15 +255,20 @@ export async function loadProjectContextFiles( const result = await loadCapability(contextFileCapability.id, { cwd: resolvedCwd }); - // Convert ContextFile items and preserve depth info - const files = result.items.map(item => { - const contextFile = item as ContextFile; - return { - path: contextFile.path, - content: contextFile.content, - depth: contextFile.depth, - }; - }); + // Materialize ContextFile items, expanding any `@path/to/file` includes + // in their content. The expansion uses the file's own directory as the + // resolution base so relative imports work the same way Claude Code, + // Goose, and other tools document. + const files = await Promise.all( + result.items.map(async item => { + const contextFile = item as ContextFile; + return { + path: contextFile.path, + content: await expandAtImports(contextFile.content, contextFile.path), + depth: contextFile.depth, + }; + }), + ); // Sort by depth (descending): higher depth (farther from cwd) comes first, // so files closer to cwd appear later and are more prominent diff --git a/packages/coding-agent/src/task/render.ts b/packages/coding-agent/src/task/render.ts index 8e3e600e6..89a44c37d 100644 --- a/packages/coding-agent/src/task/render.ts +++ b/packages/coding-agent/src/task/render.ts @@ -807,12 +807,15 @@ function renderReviewResult( // Verdict line const verdictColor = summary.overall_correctness === "correct" ? "success" : "error"; - const verdictIcon = summary.overall_correctness === "correct" ? theme.status.success : theme.status.error; + const isCorrect = summary.overall_correctness === "correct"; + const verdictIcon = isCorrect + ? theme.styledSymbol("tool.task", "accent") + : theme.fg(verdictColor, theme.status.error); lines.push( - `${continuePrefix} Patch is ${theme.fg(verdictColor, summary.overall_correctness)} ${theme.fg( - verdictColor, - verdictIcon, - )} ${theme.fg("dim", `(${(summary.confidence * 100).toFixed(0)}% confidence)`)}`, + `${continuePrefix} Patch is ${theme.fg(verdictColor, summary.overall_correctness)} ${verdictIcon} ${theme.fg( + "dim", + `(${(summary.confidence * 100).toFixed(0)}% confidence)`, + )}`, ); // Explanation preview (first ~80 chars when collapsed, full when expanded) @@ -913,7 +916,7 @@ function renderAgentResult( : needsWarning ? theme.status.warning : success - ? theme.status.success + ? theme.styledSymbol("tool.task", "accent") : theme.status.error; const iconColor = needsWarning ? "warning" : success ? "success" : mergeFailed ? "warning" : "error"; const statusText = aborted @@ -1082,7 +1085,10 @@ export function renderResult( if (!details) { const text = result.content.find(c => c.type === "text")?.text || ""; - const header = renderStatusLine({ icon: "success", title: "Task" }, theme); + const header = renderStatusLine( + { iconOverride: theme.styledSymbol("tool.task", "accent"), title: "Task" }, + theme, + ); return framedBlock(theme, width => ({ header, sections: [ @@ -1102,11 +1108,17 @@ export function renderResult( const isError = aborted || failed; const agentCount = hasResults ? details.results.length : (details.progress?.length ?? 0); const icon: ToolUIStatus = options.isPartial ? "running" : isError ? "error" : mergeFailed ? "warning" : "success"; + // Surface the dispatched agent type (e.g. `Reviewer`) alongside the count so + // the header reads `Task 16 agents: Reviewer`. All tasks in one call share a + // single `agent` type (top-level param), so one label covers the whole batch. + const agentName = args?.agent?.trim(); + const countLabel = agentCount > 0 ? `${agentCount} ${agentCount === 1 ? "agent" : "agents"}` : undefined; + const metaLabel = countLabel ? (agentName ? `${countLabel}: ${agentName}` : countLabel) : agentName; const header = renderStatusLine( { icon, title: "Task", - meta: agentCount > 0 ? [`${agentCount} ${agentCount === 1 ? "agent" : "agents"}`] : undefined, + meta: metaLabel ? [metaLabel] : undefined, }, theme, ); diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index 5ff09d52d..3dd3be7da 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -96,7 +96,7 @@ const OTHER_OPTION = "Other (type your own)"; const RECOMMENDED_SUFFIX = " (Recommended)"; function getDoneOptionLabel(): string { - return `${theme.status.success} Done selecting`; + return `${theme.symbol("tool.ask")} Done selecting`; } /** Add "(Recommended)" suffix to the option at the given index if not already present */ @@ -407,6 +407,12 @@ export class AskTool implements AgentTool { readonly description: string; readonly parameters = askSchema; readonly strict = true; + // Run alone in its tool batch. The interactive selector/editor is a single + // shared UI surface (`ExtensionUiController.showHookSelector` has no queue and + // overwrites `ctx.hookSelector` on each call), so two concurrent `ask` calls + // would clobber each other: the second steals focus and orphans the first, + // whose promise then hangs until the user aborts the whole turn. + readonly concurrency = "exclusive"; readonly loadMode = "discoverable"; constructor(private readonly session: ToolSession) { @@ -621,9 +627,7 @@ interface AskRenderArgs { /** Render a custom free-text answer as a status line plus indented continuation rows. */ function renderCustomInputLines(uiTheme: Theme, customInput: string): string[] { const lines = customInput.split("\n"); - const out: string[] = [ - ` ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", lines[0] ?? "")}`, - ]; + const out: string[] = [` ${uiTheme.styledSymbol("tool.ask", "accent")} ${uiTheme.fg("toolOutput", lines[0] ?? "")}`]; for (let i = 1; i < lines.length; i++) out.push(` ${uiTheme.fg("toolOutput", lines[i])}`); return out; } @@ -814,7 +818,12 @@ export const askToolRenderer = { const question = details.question; const hasSelection = details.customInput !== undefined || (details.selectedOptions && details.selectedOptions.length > 0); - const header = renderStatusLine({ icon: hasSelection ? "success" : "warning", title: "Ask" }, uiTheme); + const header = renderStatusLine( + hasSelection + ? { iconOverride: uiTheme.styledSymbol("tool.ask", "accent"), title: "Ask" } + : { icon: "warning", title: "Ask" }, + uiTheme, + ); const dOptions = details.options; const dSelected = details.selectedOptions; const dMulti = details.multi; diff --git a/packages/coding-agent/src/tools/bash-interactive.ts b/packages/coding-agent/src/tools/bash-interactive.ts index f8eac335d..6e51722fa 100644 --- a/packages/coding-agent/src/tools/bash-interactive.ts +++ b/packages/coding-agent/src/tools/bash-interactive.ts @@ -246,7 +246,7 @@ class BashInteractiveOverlayComponent implements Component { this.#state === "running" ? formatStatusIcon("running", this.uiTheme) : this.#state === "complete" && this.#exitCode === 0 - ? formatStatusIcon("success", this.uiTheme) + ? this.uiTheme.styledSymbol("tool.bash", "accent") : formatStatusIcon("warning", this.uiTheme); const title = this.uiTheme.fg("accent", "Console"); const statusBadge = `${this.uiTheme.fg("dim", this.uiTheme.format.bracketLeft)}${this.#stateText()}${this.uiTheme.fg("dim", this.uiTheme.format.bracketRight)}`; diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index b07f3b829..0d2125748 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -1151,11 +1151,23 @@ export function createShellRenderer(config: ShellRendererConfig) { const renderArgs = toBashRenderArgs(args, config); const cmdLines = args ? formatBashCommandLines(renderArgs, uiTheme) : undefined; const isError = result.isError === true; - const icon = options.isPartial ? "pending" : isError ? "error" : "success"; + const isPartial = options.isPartial === true; + const success = !isPartial && !isError; const header = config.showHeader === false ? undefined - : renderStatusLine({ icon, title: config.resolveTitle(args, options) }, uiTheme); + : renderStatusLine( + success + ? { + iconOverride: uiTheme.styledSymbol("tool.bash", "accent"), + title: config.resolveTitle(args, options), + } + : { + icon: isPartial ? "pending" : "error", + title: config.resolveTitle(args, options), + }, + uiTheme, + ); const details = result.details; const outputBlock = new CachedOutputBlock(); diff --git a/packages/coding-agent/src/tools/browser/render.ts b/packages/coding-agent/src/tools/browser/render.ts index e2b84bb61..b2172d486 100644 --- a/packages/coding-agent/src/tools/browser/render.ts +++ b/packages/coding-agent/src/tools/browser/render.ts @@ -146,7 +146,7 @@ function renderOpenOrCloseLine( const action = (details?.action ?? args.action ?? "open") as "open" | "close" | "run"; const status = cellStatus(isPartial, isError); const icon = - status === "complete" ? "success" : status === "error" ? "error" : status === "running" ? "running" : "pending"; + status === "complete" ? "done" : status === "error" ? "error" : status === "running" ? "running" : "pending"; let title: string; if (action === "close") { @@ -163,7 +163,10 @@ function renderOpenOrCloseLine( const url = details?.url ?? args.url; if (url) meta.push(shortenPath(url)); - const header = renderStatusLine({ icon, title, meta }, theme); + const header = + status === "complete" + ? renderStatusLine({ iconOverride: theme.styledSymbol("tool.browser", "accent"), title, meta }, theme) + : renderStatusLine({ icon, title, meta }, theme); if (!output) return new Text(header, 0, 0); const outputLines = output.split("\n").map(line => theme.fg("toolOutput", replaceTabs(line))); return new Text([header, ...outputLines].join("\n"), 0, 0); diff --git a/packages/coding-agent/src/tools/browser/tab-worker.ts b/packages/coding-agent/src/tools/browser/tab-worker.ts index c1e2ad2ac..b5af3b6a3 100644 --- a/packages/coding-agent/src/tools/browser/tab-worker.ts +++ b/packages/coding-agent/src/tools/browser/tab-worker.ts @@ -8,6 +8,7 @@ import type { Browser, Dialog, ElementHandle, + ElementScreenshotOptions, HTTPResponse, KeyInput, Page, @@ -78,6 +79,14 @@ type DialogPolicy = "accept" | "dismiss"; type DragTarget = string | { readonly x: number; readonly y: number }; type ActionabilityResult = { ok: true; x: number; y: number } | { ok: false; reason: string }; +/** + * Per-op ceiling for puppeteer-internal helpers that should resolve quickly + * (`observe`, `screenshot`, `extract`). Kept below the default 30s cell budget so a + * single stalled helper fails fast with a named error and leaves budget for the rest + * of the cell. Effective cap is `min(cellBudget, QUICK_OP_TIMEOUT_MS)`. + */ +const QUICK_OP_TIMEOUT_MS = 20_000; + interface ScreenshotOptions { selector?: string; fullPage?: boolean; @@ -404,12 +413,36 @@ async function clickQueryHandlerText( ); } +export interface InflightOp { + label: string; + startedAt: number; +} + interface ActiveRun { id: string; ac: AbortController; displays: RunResultOk["displays"]; screenshots: ScreenshotResult[]; pendingTools: Map; + /** Helper invocations currently awaiting the page/network, keyed by op id. */ + inflight: Map; + opCounter: number; +} + +/** Human-readable label for a screenshot op, used in op tracking + timeout errors. */ +export function describeScreenshot(opts?: ScreenshotOptions): string { + if (opts?.selector) return `tab.screenshot({ selector: ${JSON.stringify(opts.selector)} })`; + if (opts?.fullPage) return "tab.screenshot({ fullPage: true })"; + return "tab.screenshot()"; +} + +/** Summarize still-running helpers (oldest first) so a cell timeout names what stalled. */ +export function describeInflight(inflight: Map): string { + const now = Date.now(); + return [...inflight.values()] + .sort((a, b) => a.startedAt - b.startedAt) + .map(op => `${op.label} (${((now - op.startedAt) / 1000).toFixed(1)}s)`) + .join(", "); } export class WorkerCore { @@ -560,13 +593,21 @@ export class WorkerCore { const signal = AbortSignal.any([timeoutSignal, ac.signal]); const displays: RunResultOk["displays"] = []; const screenshots: ScreenshotResult[] = []; - const active: ActiveRun = { id: msg.id, ac, displays, screenshots, pendingTools: new Map() }; + const active: ActiveRun = { + id: msg.id, + ac, + displays, + screenshots, + pendingTools: new Map(), + inflight: new Map(), + opCounter: 0, + }; this.#active = active; try { throwIfAborted(signal); const page = this.#requirePage(); const browser = this.#requireBrowser(); - const tabApi = this.#createTabApi(msg.name, msg.timeoutMs, signal, msg.session, displays, screenshots); + const tabApi = this.#createTabApi(msg.name, msg.timeoutMs, signal, msg.session, displays, screenshots, active); const runtime = this.#ensureRuntime(msg.session); runtime.setCwd(msg.session.cwd); runtime.setRunScope({ @@ -580,11 +621,16 @@ export class WorkerCore { }); const { promise: cancelRejection, reject: rejectCancel } = Promise.withResolvers(); const onCancel = (): void => { - rejectCancel( - timeoutSignal.aborted - ? new ToolError(`Browser code execution timed out after ${msg.timeoutMs}ms`) - : new ToolAbortError(), - ); + if (timeoutSignal.aborted) { + const stalled = describeInflight(active.inflight); + rejectCancel( + new ToolError( + `Browser code execution timed out after ${msg.timeoutMs}ms${stalled ? ` (stalled on ${stalled})` : ""}`, + ), + ); + } else { + rejectCancel(new ToolAbortError()); + } // Cancel in-flight tool calls so user code's awaited proxies reject promptly. for (const pending of active.pendingTools.values()) { pending.reject(new ToolAbortError()); @@ -670,6 +716,39 @@ export class WorkerCore { else pending.reject(replyError(reply.error)); } + /** + * Wrap a tab helper so it (a) registers in the active run's in-flight map for + * timeout diagnostics and (b) honors an optional per-op deadline that fails fast + * with a named error instead of silently consuming the whole cell budget. Pass + * `Number.POSITIVE_INFINITY` for `perOpTimeoutMs` to bound the op only by the cell + * budget (used for `evaluate` running user code and for locator helpers that already + * carry puppeteer's own `.setTimeout(timeoutMs)`). + */ + async #runOp( + active: ActiveRun, + label: string, + cellSignal: AbortSignal, + perOpTimeoutMs: number, + fn: (signal: AbortSignal) => Promise, + ): Promise { + const opId = active.opCounter++; + active.inflight.set(opId, { label, startedAt: Date.now() }); + const capped = Number.isFinite(perOpTimeoutMs) && perOpTimeoutMs > 0; + const opTimeout = capped ? AbortSignal.timeout(perOpTimeoutMs) : undefined; + const opSignal = opTimeout ? AbortSignal.any([cellSignal, opTimeout]) : cellSignal; + try { + return await fn(opSignal); + } catch (err) { + // Per-op deadline fired (not the cell budget, not an explicit abort) → named, actionable error. + if (opTimeout?.aborted && !cellSignal.aborted) { + throw new ToolError(`${label} timed out after ${perOpTimeoutMs}ms`); + } + throw err; + } finally { + active.inflight.delete(opId); + } + } + #createTabApi( name: string, timeoutMs: number, @@ -677,98 +756,125 @@ export class WorkerCore { session: SessionSnapshot, displays: RunResultOk["displays"], screenshots: ScreenshotResult[], + active: ActiveRun, ): TabApi { const page = this.#requirePage(); + const quickOpMs = Math.min(timeoutMs, QUICK_OP_TIMEOUT_MS); + const INF = Number.POSITIVE_INFINITY; + const op = (label: string, perOpMs: number, fn: (sig: AbortSignal) => Promise): Promise => + this.#runOp(active, label, signal, perOpMs, fn); return { name, page, signal, url: () => page.url(), - title: () => page.title(), - goto: async (url, opts) => { - this.#clearElementCache(); - await untilAborted(signal, () => + title: () => op("tab.title()", INF, sig => untilAborted(sig, () => page.title())), + goto: (url, opts) => + op(`tab.goto(${JSON.stringify(url)})`, INF, async sig => { + this.#clearElementCache(); // Default to "load" because dev servers with HMR/WS never reach networkidle. - page.goto(url, { waitUntil: opts?.waitUntil ?? "load", timeout: timeoutMs }), - ); - }, - observe: opts => this.#collectObservation({ ...opts, signal }), - screenshot: async opts => await this.#captureScreenshot(session, displays, screenshots, signal, opts), - extract: async (format = "markdown") => { - const html = (await untilAborted(signal, () => page.content())) as string; - const result = await extractReadableFromHtml(html, page.url(), format); - if (!result) { - throw new ToolError(`tab.extract(${JSON.stringify(format)}) found no readable content on ${page.url()}`); - } - const content = format === "markdown" ? result.markdown : result.text; - if (!content) { - throw new ToolError( - `tab.extract(${JSON.stringify(format)}) produced empty ${format} content for ${page.url()}`, + await untilAborted(sig, () => + page.goto(url, { waitUntil: opts?.waitUntil ?? "load", timeout: timeoutMs }), ); - } - return content; - }, - click: async selector => { - const resolved = normalizeSelector(selector); - if (resolved.startsWith("text/")) await clickQueryHandlerText(page, resolved, timeoutMs, signal); - else await untilAborted(signal, () => page.locator(resolved).setTimeout(timeoutMs).click()); - }, - type: async (selector, text) => { - const handle = (await untilAborted(signal, () => - page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(), - )) as ElementHandle; - try { - await untilAborted(signal, () => handle.type(text, { delay: 0 })); - } finally { - await handle.dispose(); - } - }, - fill: async (selector, value) => { - await untilAborted(signal, () => - page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).fill(value), - ); - }, - press: async (key, opts) => { - const selector = opts?.selector; - if (selector) await untilAborted(signal, () => page.focus(normalizeSelector(selector))); - await untilAborted(signal, () => page.keyboard.press(key)); - }, - scroll: async (deltaX, deltaY) => { - await untilAborted(signal, () => page.mouse.wheel({ deltaX, deltaY })); - }, - drag: async (from, to) => await this.#drag(from, to, signal), - waitFor: async selector => - (await untilAborted(signal, () => - page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(), - )) as ElementHandle, - evaluate: async (fn, ...args) => - (await untilAborted(signal, () => - typeof fn === "string" ? page.evaluate(fn) : page.evaluate(fn as (...a: unknown[]) => unknown, ...args), - )) as never, - scrollIntoView: async selector => { - const handle = (await untilAborted(signal, () => - page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(), - )) as ElementHandle; - try { - await untilAborted(signal, () => - handle.evaluate(el => { - const target = el as unknown as { - scrollIntoView: (opts: { behavior: string; block: string; inline: string }) => void; - }; - target.scrollIntoView({ behavior: "instant", block: "center", inline: "center" }); - }), - ); - } finally { - await handle.dispose().catch(() => undefined); - } - }, - select: async (selector, ...values) => await this.#select(selector, values, timeoutMs, signal), - uploadFile: async (selector, ...filePaths) => - await this.#uploadFile(selector, filePaths, timeoutMs, signal, session), - waitForUrl: async (pattern, opts) => await this.#waitForUrl(pattern, opts?.timeout ?? timeoutMs, signal), - waitForResponse: async (pattern, opts) => - await this.#waitForResponse(pattern, opts?.timeout ?? timeoutMs, signal), - id: async id => await this.#resolveCachedHandle(id), + }), + observe: opts => op("tab.observe()", quickOpMs, sig => this.#collectObservation({ ...opts, signal: sig })), + screenshot: opts => + op(describeScreenshot(opts), quickOpMs, sig => + this.#captureScreenshot(session, displays, screenshots, sig, opts), + ), + extract: (format = "markdown") => + op(`tab.extract(${JSON.stringify(format)})`, quickOpMs, async sig => { + const html = (await untilAborted(sig, () => page.content())) as string; + const result = await extractReadableFromHtml(html, page.url(), format); + if (!result) { + throw new ToolError( + `tab.extract(${JSON.stringify(format)}) found no readable content on ${page.url()}`, + ); + } + const content = format === "markdown" ? result.markdown : result.text; + if (!content) { + throw new ToolError( + `tab.extract(${JSON.stringify(format)}) produced empty ${format} content for ${page.url()}`, + ); + } + return content; + }), + click: selector => + op(`tab.click(${JSON.stringify(selector)})`, INF, async sig => { + const resolved = normalizeSelector(selector); + if (resolved.startsWith("text/")) await clickQueryHandlerText(page, resolved, timeoutMs, sig); + else await untilAborted(sig, () => page.locator(resolved).setTimeout(timeoutMs).click()); + }), + type: (selector, text) => + op(`tab.type(${JSON.stringify(selector)})`, INF, async sig => { + const handle = (await untilAborted(sig, () => + page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(), + )) as ElementHandle; + try { + await untilAborted(sig, () => handle.type(text, { delay: 0 })); + } finally { + await handle.dispose(); + } + }), + fill: (selector, value) => + op(`tab.fill(${JSON.stringify(selector)})`, INF, sig => + untilAborted(sig, () => page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).fill(value)), + ), + press: (key, opts) => + op(`tab.press(${JSON.stringify(key)})`, INF, async sig => { + const selector = opts?.selector; + if (selector) await untilAborted(sig, () => page.focus(normalizeSelector(selector))); + await untilAborted(sig, () => page.keyboard.press(key)); + }), + scroll: (deltaX, deltaY) => + op("tab.scroll()", INF, sig => untilAborted(sig, () => page.mouse.wheel({ deltaX, deltaY }))), + drag: (from, to) => op("tab.drag()", INF, sig => this.#drag(from, to, sig)), + waitFor: selector => + op( + `tab.waitFor(${JSON.stringify(selector)})`, + INF, + async sig => + (await untilAborted(sig, () => + page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(), + )) as ElementHandle, + ), + evaluate: (fn, ...args) => + op("tab.evaluate()", INF, sig => + untilAborted(sig, () => + typeof fn === "string" + ? page.evaluate(fn) + : page.evaluate(fn as (...a: unknown[]) => unknown, ...args), + ), + ) as never, + scrollIntoView: selector => + op(`tab.scrollIntoView(${JSON.stringify(selector)})`, INF, async sig => { + const handle = (await untilAborted(sig, () => + page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(), + )) as ElementHandle; + try { + await untilAborted(sig, () => + handle.evaluate(el => { + const target = el as unknown as { + scrollIntoView: (opts: { behavior: string; block: string; inline: string }) => void; + }; + target.scrollIntoView({ behavior: "instant", block: "center", inline: "center" }); + }), + ); + } finally { + await handle.dispose().catch(() => undefined); + } + }), + select: (selector, ...values) => + op(`tab.select(${JSON.stringify(selector)})`, INF, sig => this.#select(selector, values, timeoutMs, sig)), + uploadFile: (selector, ...filePaths) => + op(`tab.uploadFile(${JSON.stringify(selector)})`, INF, sig => + this.#uploadFile(selector, filePaths, timeoutMs, sig, session), + ), + waitForUrl: (pattern, opts) => + op("tab.waitForUrl()", INF, sig => this.#waitForUrl(pattern, opts?.timeout ?? timeoutMs, sig)), + waitForResponse: (pattern, opts) => + op("tab.waitForResponse()", INF, sig => this.#waitForResponse(pattern, opts?.timeout ?? timeoutMs, sig)), + id: id => this.#resolveCachedHandle(id), }; } @@ -832,7 +938,21 @@ export class WorkerCore { )) as ElementHandle | null; if (!handle) throw new ToolError("Screenshot selector did not resolve to an element"); try { - buffer = (await untilAborted(signal, () => handle.screenshot({ type: "png" }))) as Buffer; + // Bring the element into view with a single instant scroll instead of puppeteer's + // scrollIntoViewIfNeeded(), whose IntersectionObserver promise can stall indefinitely + // on continuously-animating pages (WebGL / backdrop-filter "glass" effects). Best-effort. + await untilAborted(signal, () => + handle.evaluate(el => { + const target = el as unknown as { + scrollIntoView: (opts: { behavior: string; block: string; inline: string }) => void; + }; + target.scrollIntoView({ behavior: "instant", block: "center", inline: "center" }); + }), + ).catch(() => undefined); + // scrollIntoView:false skips the same IntersectionObserver check inside screenshot(); + // captureBeyondViewport (puppeteer's default) still renders the clipped region. + const shotOpts: ElementScreenshotOptions = { type: "png", scrollIntoView: false }; + buffer = (await untilAborted(signal, () => handle.screenshot(shotOpts))) as Buffer; } finally { await handle.dispose().catch(() => undefined); } diff --git a/packages/coding-agent/src/tools/debug.ts b/packages/coding-agent/src/tools/debug.ts index 61a009d2c..6dcf2b9b2 100644 --- a/packages/coding-agent/src/tools/debug.ts +++ b/packages/coding-agent/src/tools/debug.ts @@ -594,8 +594,11 @@ export const debugToolRenderer = { return markFramedBlockComponent({ render(width: number): string[] { const action = (args?.action ?? result.details?.action ?? "debug").replaceAll("_", " "); - const status = options.isPartial ? "running" : result.isError ? "error" : "success"; - const header = `${formatStatusIcon(status, theme, options.spinnerFrame)} Debug ${action}`; + const success = !options.isPartial && !result.isError; + const statusIcon = success + ? theme.styledSymbol("tool.debug", "accent") + : formatStatusIcon(options.isPartial ? "running" : "error", theme, options.spinnerFrame); + const header = `${statusIcon} Debug ${action}`; const summaryLines = result.details?.snapshot ? formatSessionSnapshot(result.details.snapshot).map(line => replaceTabs(line)) : []; diff --git a/packages/coding-agent/src/tools/eval-render.ts b/packages/coding-agent/src/tools/eval-render.ts index 71730469b..adc8288ab 100644 --- a/packages/coding-agent/src/tools/eval-render.ts +++ b/packages/coding-agent/src/tools/eval-render.ts @@ -172,7 +172,7 @@ function renderAgentProgressEvents(events: EvalStatusEvent[], theme: Theme, spin const status = agentEventStatus(event.status); const iconStatus = status === "completed" - ? "success" + ? "done" : status === "failed" ? "error" : status === "aborted" @@ -182,10 +182,13 @@ function renderAgentProgressEvents(events: EvalStatusEvent[], theme: Theme, spin : "running"; const iconColor = status === "completed" ? "success" : status === "failed" || status === "aborted" ? "error" : "accent"; - const icon = formatStatusIcon(iconStatus, theme, status === "running" ? spinnerFrame : undefined); + const icon = + status === "completed" + ? theme.styledSymbol("tool.eval", "accent") + : theme.fg(iconColor, formatStatusIcon(iconStatus, theme, status === "running" ? spinnerFrame : undefined)); const id = eventString(event.id) ?? "agent"; - let line = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", theme.bold(id))}`; + let line = `${prefix} ${icon} ${theme.fg("accent", theme.bold(id))}`; if (status === "failed" || status === "aborted") { line += ` ${formatBadge(status, iconColor, theme)}`; diff --git a/packages/coding-agent/src/tools/eval.ts b/packages/coding-agent/src/tools/eval.ts index 5f0faf0ef..67abe9e5e 100644 --- a/packages/coding-agent/src/tools/eval.ts +++ b/packages/coding-agent/src/tools/eval.ts @@ -30,7 +30,7 @@ const evalCellSchema = z.object({ language: z.enum(["py", "js"]).describe('runtime: "py" for the IPython kernel, "js" for the persistent JS VM'), code: z.string().describe("cell body, verbatim. Use top-level await freely."), title: z.string().optional().describe('short label shown in transcript (e.g. "imports", "load config")'), - timeout: z.number().int().min(1).max(600).optional().describe("per-cell timeout in seconds (1-600, default 30)"), + timeout: z.number().int().min(1).max(3600).optional().describe("per-cell timeout in seconds (1-3600, default 30)"), reset: z .boolean() .optional() diff --git a/packages/coding-agent/src/tools/gh-renderer.ts b/packages/coding-agent/src/tools/gh-renderer.ts index d34d8d9f0..1d703e701 100644 --- a/packages/coding-agent/src/tools/gh-renderer.ts +++ b/packages/coding-agent/src/tools/gh-renderer.ts @@ -163,8 +163,8 @@ function getJobStateVisual( ): { iconRaw: string; iconColor: ToolUIColor; textColor: ThemeColor } { if (job.conclusion && SUCCESS_CONCLUSIONS.has(job.conclusion)) { return { - iconRaw: theme.status.success, - iconColor: "success", + iconRaw: theme.symbol("tool.gh"), + iconColor: "accent", textColor: "success", }; } @@ -327,14 +327,21 @@ function renderFallbackComponent( const title = formatOpTitle(args.op); const meta = buildOpMeta(args); const isError = result.isError === true; - const status: ToolUIStatus = isError ? "error" : text ? "success" : "warning"; + const success = !isError && Boolean(text); const header = renderStatusLine( - { - icon: status, - title, - titleColor: isError ? "error" : "accent", - meta, - }, + success + ? { + iconOverride: theme.styledSymbol("tool.gh", "accent"), + title, + titleColor: "accent", + meta, + } + : { + icon: isError ? "error" : "warning", + title, + titleColor: isError ? "error" : "accent", + meta, + }, theme, ); @@ -438,12 +445,19 @@ export const githubToolRenderer = { if (watch) { const isError = result.isError === true; const header = renderStatusLine( - { - icon: isError ? "error" : "success", - title: "GitHub Run Watch", - titleColor: isError ? "error" : "accent", - meta: [getWatchHeader(watch)], - }, + isError + ? { + icon: "error", + title: "GitHub Run Watch", + titleColor: "error", + meta: [getWatchHeader(watch)], + } + : { + iconOverride: uiTheme.styledSymbol("tool.gh", "accent"), + title: "GitHub Run Watch", + titleColor: "accent", + meta: [getWatchHeader(watch)], + }, uiTheme, ); return framedBlock(uiTheme, width => { diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index 9e8cbd614..745fb1720 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -343,6 +343,38 @@ export function computeEssentialBuiltinNames(settings: Settings): string[] { return [...DEFAULT_ESSENTIAL_TOOL_NAMES]; } +/** + * Filter the initial active tool set when `tools.discoveryMode === "all"`. + * + * Non-essential discoverable built-ins are hidden — the model rediscovers them + * via `search_tool_bm25` and activates them on demand. A tool survives hiding + * when it is essential, explicitly requested, restored from a prior selection, + * or required by a forced tool_choice feature (`forceActive`). The last case is + * load-bearing: a named tool_choice (e.g. the eager `todo` prelude) must + * reference a tool present in the request, or the provider rejects it with 400. + */ +export function filterInitialToolsForDiscoveryAll( + initialToolNames: string[], + opts: { + loadModeOf: (name: string) => BuiltinToolLoadMode | undefined; + essentialNames: ReadonlySet; + explicitlyRequested: ReadonlySet; + restored: ReadonlySet; + forceActive: ReadonlySet; + }, +): string[] { + return initialToolNames.filter(name => { + const loadMode = opts.loadModeOf(name); + if (!loadMode) return true; // not a built-in — leave MCP/custom/extension to existing logic + if (loadMode === "essential") return true; + if (opts.essentialNames.has(name)) return true; + if (opts.explicitlyRequested.has(name)) return true; + if (opts.restored.has(name)) return true; + if (opts.forceActive.has(name)) return true; + return false; + }); +} + /** * Public callable factory map. External callers may invoke `BUILTIN_TOOLS.read(session)` or * `BUILTIN_TOOLS[name](session)` to construct a tool directly. diff --git a/packages/coding-agent/src/tools/inspect-image-renderer.ts b/packages/coding-agent/src/tools/inspect-image-renderer.ts index 604a04d52..d2ff44e31 100644 --- a/packages/coding-agent/src/tools/inspect-image-renderer.ts +++ b/packages/coding-agent/src/tools/inspect-image-renderer.ts @@ -53,12 +53,19 @@ export const inspectImageToolRenderer = { const details = result.details; const rawPath = details?.imagePath ?? args?.path ?? ""; const pathDisplay = rawPath ? shortenPath(rawPath) : "image"; + const success = !result.isError; const header = renderStatusLine( - { - icon: result.isError ? "error" : "success", - title: "Inspect", - description: pathDisplay, - }, + success + ? { + iconOverride: uiTheme.styledSymbol("tool.inspectImage", "accent"), + title: "Inspect", + description: pathDisplay, + } + : { + icon: "error", + title: "Inspect", + description: pathDisplay, + }, uiTheme, ); diff --git a/packages/coding-agent/src/tools/job.ts b/packages/coding-agent/src/tools/job.ts index c0f326d5d..070d17710 100644 --- a/packages/coding-agent/src/tools/job.ts +++ b/packages/coding-agent/src/tools/job.ts @@ -355,7 +355,7 @@ const PREVIEW_LINE_WIDTH = 80; function statusToIcon(status: JobSnapshot["status"]): ToolUIStatus { switch (status) { case "completed": - return "success"; + return "done"; case "failed": return "error"; case "cancelled": @@ -468,11 +468,14 @@ export const jobToolRenderer = { itemType: "job", renderItem: job => { const lines: string[] = []; - const icon = formatStatusIcon( - statusToIcon(job.status), - uiTheme, - job.status === "running" ? options.spinnerFrame : undefined, - ); + const icon = + job.status === "completed" + ? uiTheme.styledSymbol("tool.job", "accent") + : formatStatusIcon( + statusToIcon(job.status), + uiTheme, + job.status === "running" ? options.spinnerFrame : undefined, + ); const typeBadge = formatBadge(job.type, statusToColor(job.status), uiTheme); const idText = uiTheme.fg("muted", job.id); const rawLabelLines = (job.label || "(no label)").split(/\r?\n/); diff --git a/packages/coding-agent/src/tools/memory-render.ts b/packages/coding-agent/src/tools/memory-render.ts index 2430eef6c..30adfae43 100644 --- a/packages/coding-agent/src/tools/memory-render.ts +++ b/packages/coding-agent/src/tools/memory-render.ts @@ -49,10 +49,11 @@ function queryHeader( icon: ToolUIStatus, theme: Theme, meta?: string[], + iconOverride?: string, ): string { const trimmed = replaceTabs((query ?? "").trim()); const description = trimmed ? truncateToWidth(trimmed, 80, Ellipsis.Unicode) : undefined; - return renderStatusLine({ icon, title, description, meta }, theme); + return renderStatusLine({ icon, iconOverride, title, description, meta }, theme); } function retainComponent(contents: string[], header: string, getExpanded: () => boolean, theme: Theme): Component { @@ -96,7 +97,11 @@ export const retainToolRenderer = { // trailing period so it reads cleanly as a status meta segment. const summary = resultText(result).replace(/\.$/, ""); const header = renderStatusLine( - { icon: "success", title: "Retain", meta: summary ? [summary] : undefined }, + { + iconOverride: theme.styledSymbol("tool.memory", "accent"), + title: "Retain", + meta: summary ? [summary] : undefined, + }, theme, ); return retainComponent(contents, header, () => options.expanded, theme); @@ -121,9 +126,11 @@ export const recallToolRenderer = { const text = resultText(result); const match = text.match(/^Found (\d+) relevant/); const found = match ? Number(match[1]) : 0; - const icon: ToolUIStatus = found > 0 ? "success" : "warning"; const meta = [found > 0 ? `${found} found` : "no matches"]; - const header = queryHeader("Recall", args?.query, icon, theme, meta); + const header = + found > 0 + ? queryHeader("Recall", args?.query, "success", theme, meta, theme.styledSymbol("tool.memory", "accent")) + : queryHeader("Recall", args?.query, "warning", theme, meta); if (found === 0) { return new Text(header, 0, 0); } @@ -163,7 +170,14 @@ export const reflectToolRenderer = { if (result.isError) { return new Text(formatErrorMessage(resultText(result) || "Reflect failed", theme), 0, 0); } - const header = queryHeader("Reflect", args?.query, "success", theme); + const header = queryHeader( + "Reflect", + args?.query, + "success", + theme, + undefined, + theme.styledSymbol("tool.memory", "accent"), + ); const answer = resultText(result); const answerLines = answer.split("\n").filter(line => line.trim().length > 0); return createCachedComponent( diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 86124528f..9b39f4b97 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -9,7 +9,12 @@ import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { getRemoteDir, logger, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils"; import * as z from "zod/v4"; -import { canonicalSnapshotKey, getFileSnapshotStore, recordFileSnapshot } from "../edit/file-snapshot-store"; +import { + canonicalSnapshotKey, + getFileSnapshotStore, + recordFileSnapshot, + SNAPSHOT_MAX_BYTES, +} from "../edit/file-snapshot-store"; import { normalizeToLF } from "../edit/normalize"; import { isNotebookPath, readEditableNotebookText } from "../edit/notebook"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; @@ -30,6 +35,7 @@ import { } from "../session/streaming-output"; import { fileHyperlink, renderCodeCell, renderMarkdownCell, renderStatusLine, tryResolveInternalUrlSync } from "../tui"; import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block"; +import { buildLineEntriesWithBlockContext, type LineEntry, lineEntriesToPlainText } from "../utils/block-context"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; import { ImageInputTooLargeError, loadImageInput, MAX_IMAGE_INPUT_BYTES } from "../utils/image-loading"; import { convertFileWithMarkit } from "../utils/markit"; @@ -108,6 +114,15 @@ const PROSE_SUMMARY_EXTENSIONS = new Set([".md", ".txt"]); // Remote mount path prefix (sshfs mounts) - skip fuzzy matching to avoid hangs const REMOTE_MOUNT_PREFIX = getRemoteDir() + path.sep; +async function readBracketContextFullLines(absolutePath: string, fileSize: number): Promise { + if (fileSize > SNAPSHOT_MAX_BYTES) return undefined; + try { + return normalizeToLF(await Bun.file(absolutePath).text()).split("\n"); + } catch { + return undefined; + } +} + function isRemoteMountPath(absolutePath: string): boolean { return absolutePath.startsWith(REMOTE_MOUNT_PREFIX); } @@ -174,6 +189,21 @@ function formatTextWithMode( return text; } +const BRACKET_CONTEXT_ELLIPSIS = "…"; + +function formatLineEntryWithMode(entry: LineEntry, shouldAddHashLines: boolean, shouldAddLineNumbers: boolean): string { + if (entry.kind === "ellipsis") return BRACKET_CONTEXT_ELLIPSIS; + return formatSingleLine(entry.lineNumber, entry.text, shouldAddHashLines, shouldAddLineNumbers); +} + +function formatLineEntriesWithMode( + entries: readonly LineEntry[], + shouldAddHashLines: boolean, + shouldAddLineNumbers: boolean, +): string { + return entries.map(entry => formatLineEntryWithMode(entry, shouldAddHashLines, shouldAddLineNumbers)).join("\n"); +} + const BRACE_PAIRS: Record = { "{": "}", "(": ")", "[": "]" }; const BRACE_TAIL_TRAILING_RE = /^[;,)\]}]*$/; @@ -915,6 +945,21 @@ export class ReadTool implements AgentTool { emittedHashlineHeader = true; return prependHashlineHeader(formatted, hashContext); }; + const formatLineEntries = (entries: readonly LineEntry[], startNum: number): string => { + const firstLine = entries.find(entry => entry.kind === "line"); + details.displayContent = { + text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS), + startLine: firstLine?.kind === "line" ? firstLine.lineNumber : startNum, + }; + const formatted = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers); + if (!hashContext || emittedHashlineHeader) return formatted; + emittedHashlineHeader = true; + return prependHashlineHeader(formatted, hashContext); + }; + const buildLineEntries = (endLineDisplay: number): LineEntry[] => + buildLineEntriesWithBlockContext(allLines, [{ startLine: startLineDisplay, endLine: endLineDisplay }], { + path: options.sourcePath, + }); let outputText: string; let truncationInfo: @@ -946,7 +991,12 @@ export class ReadTool implements AgentTool { options: { direction: "head", startLine: startLineDisplay, totalFileLines: totalLines }, }; } else if (truncation.truncated) { - outputText = formatText(truncation.content, startLineDisplay); + const outputLines = truncation.outputLines ?? countTextLines(truncation.content); + const endLineDisplay = startLineDisplay + Math.max(0, outputLines - 1); + outputText = + options.raw === true + ? formatText(truncation.content, startLineDisplay) + : formatLineEntries(buildLineEntries(endLineDisplay), startLineDisplay); details.truncation = truncation; truncationInfo = { result: truncation, @@ -956,10 +1006,16 @@ export class ReadTool implements AgentTool { const remaining = allLines.length - (startLine + userLimitedLines); const nextOffset = startLine + userLimitedLines + 1; - outputText = formatText(selectedContent, startLineDisplay); + outputText = + options.raw === true + ? formatText(selectedContent, startLineDisplay) + : formatLineEntries(buildLineEntries(endLine), startLineDisplay); outputText += `\n\n[${remaining} more lines in ${options.entityLabel}. Use :${nextOffset} to continue]`; } else { - outputText = formatText(truncation.content, startLineDisplay); + outputText = + options.raw === true + ? formatText(truncation.content, startLineDisplay) + : formatLineEntries(buildLineEntries(endLine), startLineDisplay); } resultBuilder.text(outputText); @@ -1011,21 +1067,37 @@ export class ReadTool implements AgentTool { if (options.sourceUrl) resultBuilder.sourceUrl(options.sourceUrl); if (options.sourceInternal) resultBuilder.sourceInternal(options.sourceInternal); - const parts: string[] = []; const outOfBounds: LineRange[] = []; + const visibleSpans: Array<{ startLine: number; endLine: number }> = []; + const rawParts: string[] = []; for (const range of ranges) { if (range.startLine > totalLines) { outOfBounds.push(range); continue; } const effectiveEnd = Math.min(range.endLine ?? totalLines, totalLines); - const sliced = allLines.slice(range.startLine - 1, effectiveEnd).join("\n"); - const formatted = formatTextWithMode(sliced, range.startLine, shouldAddHashLines, shouldAddLineNumbers); - parts.push(hashContext && !emittedHashlineHeader ? prependHashlineHeader(formatted, hashContext) : formatted); - if (hashContext) emittedHashlineHeader = true; + visibleSpans.push({ startLine: range.startLine, endLine: effectiveEnd }); + if (options.raw === true) { + rawParts.push(allLines.slice(range.startLine - 1, effectiveEnd).join("\n")); + } } - const outputText = parts.length > 0 ? parts.join("\n\n…\n\n") : ""; + let outputText = ""; + if (options.raw === true) { + outputText = rawParts.length > 0 ? rawParts.join("\n\n…\n\n") : ""; + } else if (visibleSpans.length > 0) { + const entries = buildLineEntriesWithBlockContext(allLines, visibleSpans, { path: options.sourcePath }); + const firstLine = entries.find(entry => entry.kind === "line"); + if (firstLine?.kind === "line") { + details.displayContent = { + text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS), + startLine: firstLine.lineNumber, + }; + } + const formatted = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers); + outputText = hashContext && !emittedHashlineHeader ? prependHashlineHeader(formatted, hashContext) : formatted; + if (hashContext) emittedHashlineHeader = true; + } const notices: string[] = []; for (const range of outOfBounds) { const bound = range.endLine !== undefined ? `${range.startLine}-${range.endLine}` : `${range.startLine}`; @@ -1046,6 +1118,7 @@ export class ReadTool implements AgentTool { async #readLocalFileMultiRange( absolutePath: string, ranges: readonly LineRange[], + fileSize: number, parsed: ParsedSelector, displayMode: { hashLines: boolean; lineNumbers: boolean }, suffixResolution: { from: string; to: string } | undefined, @@ -1053,6 +1126,7 @@ export class ReadTool implements AgentTool { ): Promise<{ outputText: string; columnTruncated: number; + displayContent?: { text: string; startLine: number }; bridgeResult?: AgentToolResult; }> { const rawSelector = isRawSelector(parsed); @@ -1085,7 +1159,11 @@ export class ReadTool implements AgentTool { const blocks: string[] = []; const notices: string[] = []; + const visibleSpans: Array<{ startLine: number; endLine: number }> = []; + const displayLineByNumber = new Map(); + const fullLines = rawSelector ? undefined : await readBracketContextFullLines(absolutePath, fileSize); let columnTruncated = 0; + let displayContent: { text: string; startLine: number } | undefined; for (const range of ranges) { const rangeStart = range.startLine - 1; // 0-indexed @@ -1125,11 +1203,43 @@ export class ReadTool implements AgentTool { } if (cloned) displayLines = cloned; } - const blockText = displayLines.join("\n"); - blocks.push(formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers)); + const endLine = range.startLine + Math.max(0, displayLines.length - 1); + visibleSpans.push({ startLine: range.startLine, endLine }); + for (let i = 0; i < displayLines.length; i++) { + displayLineByNumber.set(range.startLine + i, displayLines[i] ?? ""); + } + if (!fullLines || rawSelector) { + const blockText = displayLines.join("\n"); + blocks.push(formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers)); + } } - let outputText = blocks.join("\n\n…\n\n"); + let outputText: string; + if (!rawSelector && fullLines && visibleSpans.length > 0) { + const entries = buildLineEntriesWithBlockContext( + fullLines, + visibleSpans, + { path: absolutePath }, + { + lineText: (lineNumber, sourceText) => { + const visibleText = displayLineByNumber.get(lineNumber); + if (visibleText !== undefined) return visibleText; + if (maxColumns <= 0) return sourceText; + const truncated = truncateLine(sourceText, maxColumns); + if (truncated.wasTruncated) columnTruncated = maxColumns; + return truncated.text; + }, + }, + ); + const firstLine = entries.find(entry => entry.kind === "line"); + displayContent = { + text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS), + startLine: firstLine?.kind === "line" ? firstLine.lineNumber : (visibleSpans[0]?.startLine ?? 1), + }; + outputText = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers); + } else { + outputText = blocks.join("\n\n…\n\n"); + } if (shouldAddHashLines && outputText) { const tag = await recordFileSnapshot(this.session, absolutePath); if (tag) { @@ -1139,7 +1249,7 @@ export class ReadTool implements AgentTool { if (notices.length > 0) { outputText = outputText ? `${outputText}\n${notices.join("\n")}` : notices.join("\n"); } - return { outputText, columnTruncated }; + return { outputText, columnTruncated, displayContent }; } async #readArchiveDirectory( @@ -1818,6 +1928,7 @@ export class ReadTool implements AgentTool { const multiResult = await this.#readLocalFileMultiRange( absolutePath, parsed.ranges, + fileSize, parsed, displayMode, suffixResolution, @@ -1826,7 +1937,7 @@ export class ReadTool implements AgentTool { if (multiResult.bridgeResult) return multiResult.bridgeResult; content = [{ type: "text", text: multiResult.outputText }]; sourcePath = absolutePath; - details = {}; + details = multiResult.displayContent ? { displayContent: multiResult.displayContent } : {}; if (multiResult.columnTruncated > 0) { columnTruncated = multiResult.columnTruncated; } @@ -1930,6 +2041,15 @@ export class ReadTool implements AgentTool { if (cloned) displayLines = cloned; } + const displayLineByNumber = new Map(); + for (let i = 0; i < displayLines.length; i++) { + displayLineByNumber.set(startLineDisplay + i, displayLines[i] ?? ""); + } + const bracketContextFullLines = rawSelector + ? undefined + : await readBracketContextFullLines(absolutePath, fileSize); + const displayedEndLine = startLineDisplay + Math.max(0, displayLines.length - 1); + const selectedContent = displayLines.join("\n"); const userLimitedLines = collectedLines.length; @@ -1979,6 +2099,33 @@ export class ReadTool implements AgentTool { emittedHashlineHeader = true; return prependHashlineHeader(formatted, hashContext); }; + const formatBracketAwareText = (): string | undefined => { + if (!bracketContextFullLines) return undefined; + const entries = buildLineEntriesWithBlockContext( + bracketContextFullLines, + [{ startLine: startLineDisplay, endLine: displayedEndLine }], + { path: absolutePath }, + { + lineText: (lineNumber, sourceText) => { + const visibleText = displayLineByNumber.get(lineNumber); + if (visibleText !== undefined) return visibleText; + if (maxColumns <= 0) return sourceText; + const truncated = truncateLine(sourceText, maxColumns); + if (truncated.wasTruncated) columnTruncated = maxColumns; + return truncated.text; + }, + }, + ); + const firstLine = entries.find(entry => entry.kind === "line"); + capturedDisplayContent = { + text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS), + startLine: firstLine?.kind === "line" ? firstLine.lineNumber : startLineDisplay, + }; + const formatted = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers); + if (!hashContext || emittedHashlineHeader) return formatted; + emittedHashlineHeader = true; + return prependHashlineHeader(formatted, hashContext); + }; let outputText: string; @@ -2005,7 +2152,7 @@ export class ReadTool implements AgentTool { options: { direction: "head", startLine: startLineDisplay, totalFileLines }, }; } else if (truncation.truncated) { - outputText = formatText(truncation.content, startLineDisplay); + outputText = formatBracketAwareText() ?? formatText(truncation.content, startLineDisplay); details = { truncation }; sourcePath = absolutePath; truncationInfo = { @@ -2016,13 +2163,13 @@ export class ReadTool implements AgentTool { const remaining = totalFileLines - (startLine + userLimitedLines); const nextOffset = startLine + userLimitedLines + 1; - outputText = formatText(truncation.content, startLineDisplay); + outputText = formatBracketAwareText() ?? formatText(truncation.content, startLineDisplay); outputText += `\n\n[${remaining} more lines in file. Use :${nextOffset} to continue]`; details = {}; sourcePath = absolutePath; } else { // No truncation, no user limit exceeded - outputText = formatText(truncation.content, startLineDisplay); + outputText = formatBracketAwareText() ?? formatText(truncation.content, startLineDisplay); details = {}; sourcePath = absolutePath; } diff --git a/packages/coding-agent/src/tools/render-utils.ts b/packages/coding-agent/src/tools/render-utils.ts index 7e7ab979e..65dc8daf7 100644 --- a/packages/coding-agent/src/tools/render-utils.ts +++ b/packages/coding-agent/src/tools/render-utils.ts @@ -133,6 +133,8 @@ export function formatStatusIcon(status: ToolUIStatus, theme: Theme, spinnerFram switch (status) { case "success": return theme.styledSymbol("status.success", "success"); + case "done": + return theme.styledSymbol("status.done", "success"); case "error": return theme.styledSymbol("status.error", "error"); case "warning": @@ -276,7 +278,7 @@ export function formatCodeFrameLine( // Tool UI Helpers // ============================================================================= -export type ToolUIStatus = "success" | "error" | "warning" | "info" | "pending" | "running" | "aborted"; +export type ToolUIStatus = "success" | "done" | "error" | "warning" | "info" | "pending" | "running" | "aborted"; export type ToolUIColor = "success" | "error" | "warning" | "accent" | "muted"; export interface ToolUITitleOptions { diff --git a/packages/coding-agent/src/tools/resolve.ts b/packages/coding-agent/src/tools/resolve.ts index 15f22d406..4caf26626 100644 --- a/packages/coding-agent/src/tools/resolve.ts +++ b/packages/coding-agent/src/tools/resolve.ts @@ -241,7 +241,7 @@ export const resolveToolRenderer = { const isApply = action === "apply" && !result.isError; const isFailedApply = action === "apply" && result.isError; const bgColor = result.isError ? "error" : isApply ? "success" : "warning"; - const icon = isApply ? uiTheme.status.success : uiTheme.status.error; + const icon = isApply ? uiTheme.styledSymbol("tool.resolve", "accent") : uiTheme.status.error; const verb = isApply ? "Accept" : isFailedApply ? "Failed" : "Discard"; const separator = ": "; const separatorIndex = label.indexOf(separator); diff --git a/packages/coding-agent/src/tools/review.ts b/packages/coding-agent/src/tools/review.ts index 05b597322..0aa021ebe 100644 --- a/packages/coding-agent/src/tools/review.ts +++ b/packages/coding-agent/src/tools/review.ts @@ -169,7 +169,7 @@ export const reportFindingTool: AgentTool c.type === "text")?.text ?? ""; const outputBlock = new CachedOutputBlock(); diff --git a/packages/coding-agent/src/tools/todo.ts b/packages/coding-agent/src/tools/todo.ts index bc5530977..b50c69c9c 100644 --- a/packages/coding-agent/src/tools/todo.ts +++ b/packages/coding-agent/src/tools/todo.ts @@ -881,7 +881,14 @@ export const todoToolRenderer = { keys.add(task.content); } const allTasks = phases.flatMap(phase => phase.tasks); - const header = renderStatusLine({ icon: "success", title: "Todo", meta: [`${allTasks.length} tasks`] }, uiTheme); + const header = renderStatusLine( + { + iconOverride: uiTheme.styledSymbol("tool.todo", "accent"), + title: "Todo", + meta: [`${allTasks.length} tasks`], + }, + uiTheme, + ); if (allTasks.length === 0) { const fallback = result.content?.find(content => content.type === "text")?.text ?? "No todos"; return new Text(`${header}\n ${uiTheme.fg("dim", fallback)}`, 0, 0); diff --git a/packages/coding-agent/src/tools/tool-timeouts.ts b/packages/coding-agent/src/tools/tool-timeouts.ts index cbd6ddc54..8d5342b93 100644 --- a/packages/coding-agent/src/tools/tool-timeouts.ts +++ b/packages/coding-agent/src/tools/tool-timeouts.ts @@ -9,7 +9,7 @@ export interface ToolTimeoutConfig { export const TOOL_TIMEOUTS = { bash: { default: 300, min: 1, max: 3600 }, - eval: { default: 30, min: 1, max: 600 }, + eval: { default: 30, min: 1, max: 3600 }, browser: { default: 30, min: 1, max: 300 }, ssh: { default: 60, min: 1, max: 3600 }, fetch: { default: 20, min: 1, max: 45 }, diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index 1ad1a3cb9..6848060a2 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -1081,7 +1081,7 @@ export const writeToolRenderer = { : ""; const header = renderStatusLine( { - icon: "success", + iconOverride: uiTheme.styledSymbol("tool.write", "accent"), title: "Write", description: `${langIcon} ${pathDisplay}${lineSuffix}${execSuffix}`, }, diff --git a/packages/coding-agent/src/tui/code-cell.ts b/packages/coding-agent/src/tui/code-cell.ts index 76fd38655..0e26c116d 100644 --- a/packages/coding-agent/src/tui/code-cell.ts +++ b/packages/coding-agent/src/tui/code-cell.ts @@ -50,7 +50,7 @@ function formatHeader(options: CodeCellOptions, theme: Theme): { title: string; if (status) { const icon = formatStatusIcon( status === "complete" - ? "success" + ? "done" : status === "error" ? "error" : status === "warning" diff --git a/packages/coding-agent/src/utils/block-context.ts b/packages/coding-agent/src/utils/block-context.ts new file mode 100644 index 000000000..5b450cf97 --- /dev/null +++ b/packages/coding-agent/src/utils/block-context.ts @@ -0,0 +1,312 @@ +import { enclosingBlockBoundaries } from "@oh-my-pi/pi-natives"; +import { logger } from "@oh-my-pi/pi-utils"; + +const OPEN_TO_CLOSE: Record = { + "(": ")", + "[": "]", + "{": "}", +}; + +const CLOSE_TO_OPEN: Record = { + ")": "(", + "]": "[", + "}": "{", +}; + +export interface LineSpan { + startLine: number; + endLine: number; +} + +/** Where the source came from, so tree-sitter can pick a grammar. */ +export interface BlockContextSource { + path?: string; + lang?: string; +} + +export type LineEntry = { kind: "line"; lineNumber: number; text: string; context: boolean } | { kind: "ellipsis" }; + +interface StackEntry { + opener: string; + lineNumber: number; + text: string; + visible: boolean; +} + +type ScannerMode = "code" | "single" | "double" | "template" | "blockComment"; + +function normalizeLineSpans(spans: readonly LineSpan[], totalLines: number): LineSpan[] { + if (totalLines <= 0) return []; + const normalized: LineSpan[] = []; + for (const span of spans) { + const startLine = Math.max(1, Math.trunc(span.startLine)); + const endLine = Math.min(totalLines, Math.trunc(span.endLine)); + if (endLine < startLine) continue; + normalized.push({ startLine, endLine }); + } + if (normalized.length <= 1) return normalized; + normalized.sort((left, right) => left.startLine - right.startLine || left.endLine - right.endLine); + const merged: LineSpan[] = []; + for (const span of normalized) { + const previous = merged[merged.length - 1]; + if (previous && span.startLine <= previous.endLine + 1) { + previous.endLine = Math.max(previous.endLine, span.endLine); + continue; + } + merged.push({ ...span }); + } + return merged; +} + +function visibleLineNumbers(spans: readonly LineSpan[]): Set { + const visible = new Set(); + for (const span of spans) { + for (let line = span.startLine; line <= span.endLine; line++) { + visible.add(line); + } + } + return visible; +} + +function hasEveryLineVisible(visible: ReadonlySet, totalLines: number): boolean { + return totalLines > 0 && visible.size >= totalLines; +} + +/** Collapse a set of visible line numbers into sorted, merged inclusive spans. */ +function visibleSetToSpans(visible: ReadonlySet): LineSpan[] { + const sorted = [...visible].sort((left, right) => left - right); + const spans: LineSpan[] = []; + for (const line of sorted) { + const previous = spans[spans.length - 1]; + if (previous && line <= previous.endLine + 1) { + previous.endLine = line; + continue; + } + spans.push({ startLine: line, endLine: line }); + } + return spans; +} + +/** + * Tree-sitter-backed block boundaries. For each multi-line named node whose + * span crosses the visible window, the native side returns the boundary line + * outside that window (closer when the opener is shown, opener when the closer + * is shown). Returns `null` when the language is unrecognized or the source has + * a syntax error so the caller can fall back to a lexical bracket scan. + */ +function nativeBlockContext( + fullLines: readonly string[], + visible: ReadonlySet, + source: BlockContextSource, +): Map | null { + if (!source.path && !source.lang) return null; + const ranges = visibleSetToSpans(visible); + if (ranges.length === 0) return new Map(); + let boundaries: number[] | null; + try { + boundaries = enclosingBlockBoundaries({ + code: fullLines.join("\n"), + path: source.path, + lang: source.lang, + ranges, + }); + } catch (error) { + logger.debug("enclosingBlockBoundaries failed; using lexical bracket fallback", { error }); + return null; + } + if (boundaries === null) return null; + const context = new Map(); + for (const lineNumber of boundaries) { + if (visible.has(lineNumber)) continue; + context.set(lineNumber, fullLines[lineNumber - 1] ?? ""); + } + return context; +} + +function findMatchingStackIndex(stack: readonly StackEntry[], opener: string): number { + for (let index = stack.length - 1; index >= 0; index--) { + if (stack[index].opener === opener) return index; + } + return -1; +} + +function isHashCommentStart(line: string, index: number): boolean { + if (line[index] !== "#") return false; + for (let i = 0; i < index; i++) { + const ch = line[i]; + if (ch !== " " && ch !== "\t") return false; + } + return true; +} + +/** + * Lexical bracket-matching fallback for sources tree-sitter can't parse + * (unknown extensions, syntax errors). Pairs `()[]{}` while skipping strings + * and line/block comments, and reports the matching line when one endpoint is + * visible and the other is not. + */ +function lexicalBracketContext(fullLines: readonly string[], visible: ReadonlySet): Map { + const context = new Map(); + const stack: StackEntry[] = []; + let mode: ScannerMode = "code"; + let escaped = false; + + for (let lineIndex = 0; lineIndex < fullLines.length; lineIndex++) { + const lineNumber = lineIndex + 1; + const line = fullLines[lineIndex] ?? ""; + const lineVisible = visible.has(lineNumber); + let index = 0; + while (index < line.length) { + const ch = line[index]; + const next = index + 1 < line.length ? line[index + 1] : ""; + + if (mode === "blockComment") { + if (ch === "*" && next === "/") { + mode = "code"; + index += 2; + continue; + } + index++; + continue; + } + + if (mode === "single" || mode === "double" || mode === "template") { + if (escaped) { + escaped = false; + index++; + continue; + } + if (ch === "\\") { + escaped = true; + index++; + continue; + } + if ( + (mode === "single" && ch === "'") || + (mode === "double" && ch === '"') || + (mode === "template" && ch === "`") + ) { + mode = "code"; + } + index++; + continue; + } + + if (ch === "/" && next === "/") break; + if (ch === "/" && next === "*") { + mode = "blockComment"; + index += 2; + continue; + } + if (isHashCommentStart(line, index)) break; + if (ch === "'") { + mode = "single"; + escaped = false; + index++; + continue; + } + if (ch === '"') { + mode = "double"; + escaped = false; + index++; + continue; + } + if (ch === "`") { + mode = "template"; + escaped = false; + index++; + continue; + } + + if (OPEN_TO_CLOSE[ch]) { + stack.push({ opener: ch, lineNumber, text: line, visible: lineVisible }); + index++; + continue; + } + + const opener = CLOSE_TO_OPEN[ch]; + if (opener) { + const matchIndex = findMatchingStackIndex(stack, opener); + if (matchIndex !== -1) { + const [matched] = stack.splice(matchIndex); + if (matched) { + if (lineVisible && !matched.visible) context.set(matched.lineNumber, matched.text); + if (matched.visible && !lineVisible) context.set(lineNumber, line); + } + } + } + + index++; + } + + if (mode === "single" || mode === "double") { + mode = "code"; + escaped = false; + } + } + + for (const lineNumber of visible) context.delete(lineNumber); + return context; +} + +/** + * Resolve the off-window boundary lines for a visible window: tree-sitter + * syntactic spans first (covers brace and indentation languages), falling back + * to a lexical bracket scan when the grammar is unavailable. Returns a map of + * `lineNumber → source text` for the lines to surface, never including a line + * already visible. + */ +export function findBlockContextLines( + fullLines: readonly string[], + visibleInput: ReadonlySet | readonly number[], + source: BlockContextSource = {}, +): Map { + const visible = visibleInput instanceof Set ? visibleInput : new Set(visibleInput); + if (visible.size === 0 || hasEveryLineVisible(visible, fullLines.length)) return new Map(); + return nativeBlockContext(fullLines, visible, source) ?? lexicalBracketContext(fullLines, visible); +} + +/** + * Build display entries for `visibleSpans` plus any off-window block-boundary + * lines, in source order, with `{ kind: "ellipsis" }` markers inserted across + * non-contiguous gaps. `options.lineText` lets callers substitute display text + * (e.g. column-truncated lines) for a given line number. + */ +export function buildLineEntriesWithBlockContext( + fullLines: readonly string[], + visibleSpans: readonly LineSpan[], + source: BlockContextSource = {}, + options: { + lineText?: (lineNumber: number, sourceText: string, context: boolean) => string; + } = {}, +): LineEntry[] { + const spans = normalizeLineSpans(visibleSpans, fullLines.length); + const visible = visibleLineNumbers(spans); + const context = findBlockContextLines(fullLines, visible, source); + const allLines = new Set(visible); + for (const lineNumber of context.keys()) allLines.add(lineNumber); + + const sorted = [...allLines].sort((left, right) => left - right); + const entries: LineEntry[] = []; + let previousLine: number | undefined; + for (const lineNumber of sorted) { + if (previousLine !== undefined && lineNumber > previousLine + 1) { + entries.push({ kind: "ellipsis" }); + } + const sourceText = fullLines[lineNumber - 1] ?? ""; + const isContext = context.has(lineNumber); + entries.push({ + kind: "line", + lineNumber, + text: options.lineText?.(lineNumber, sourceText, isContext) ?? sourceText, + context: isContext, + }); + previousLine = lineNumber; + } + + return entries; +} + +export function lineEntriesToPlainText(entries: readonly LineEntry[], ellipsis = "…"): string { + return entries.map(entry => (entry.kind === "ellipsis" ? ellipsis : entry.text)).join("\n"); +} diff --git a/packages/coding-agent/src/utils/image-loading.ts b/packages/coding-agent/src/utils/image-loading.ts index 9f172188d..c643b626f 100644 --- a/packages/coding-agent/src/utils/image-loading.ts +++ b/packages/coding-agent/src/utils/image-loading.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs/promises"; import type { ImageContent } from "@oh-my-pi/pi-ai"; import { formatBytes, readImageMetadata, SUPPORTED_IMAGE_MIME_TYPES } from "@oh-my-pi/pi-utils"; import { resolveReadPath } from "../tools/path-utils"; -import { formatDimensionNote, resizeImage } from "./image-resize"; +import { formatDimensionNote, type ImageResizeOptions, resizeImage } from "./image-resize"; export const MAX_IMAGE_INPUT_BYTES = 20 * 1024 * 1024; export const SUPPORTED_INPUT_IMAGE_MIME_TYPES = SUPPORTED_IMAGE_MIME_TYPES; @@ -50,6 +50,36 @@ export async function ensureSupportedImageInput(image: ImageContent): Promise { + if (!images || images.length === 0) return undefined; + const normalized: ImageContent[] = []; + for (const image of images) { + try { + const resized = await resizeImage(image, options?.resize); + normalized.push({ type: "image", data: resized.data, mimeType: resized.mimeType }); + } catch { + // Preserve existing caller behavior for decode/resize failures: keep the + // user's image block rather than dropping it from the turn. + normalized.push(image); + } + } + return normalized; +} + export async function loadImageInput(options: LoadImageInputOptions): Promise { const maxBytes = options.maxBytes ?? MAX_IMAGE_INPUT_BYTES; const resolvedPath = options.resolvedPath ?? resolveReadPath(options.path, options.cwd); diff --git a/packages/coding-agent/src/web/search/providers/codex.ts b/packages/coding-agent/src/web/search/providers/codex.ts index 4b6a773a5..5cd46ecfd 100644 --- a/packages/coding-agent/src/web/search/providers/codex.ts +++ b/packages/coding-agent/src/web/search/providers/codex.ts @@ -8,7 +8,7 @@ */ import * as os from "node:os"; import { type AuthStorage, getBundledModels } from "@oh-my-pi/pi-ai"; -import { decodeJwt } from "@oh-my-pi/pi-ai/utils/oauth/openai-codex"; +import { decodeJwt } from "@oh-my-pi/pi-ai/oauth/openai-codex"; import { $env, readSseJson } from "@oh-my-pi/pi-utils"; import packageJson from "../../../../package.json" with { type: "json" }; import type { SearchResponse, SearchSource } from "../../../web/search/types"; diff --git a/packages/coding-agent/src/web/search/render.ts b/packages/coding-agent/src/web/search/render.ts index 2e802f606..5c3f4e5a9 100644 --- a/packages/coding-agent/src/web/search/render.ts +++ b/packages/coding-agent/src/web/search/render.ts @@ -117,13 +117,21 @@ export function renderSearchResult( : searchQueries[0] ? truncateToWidth(searchQueries[0], 80) : undefined; + const success = sourceCount > 0; const header = renderStatusLine( - { - icon: sourceCount > 0 ? "success" : "warning", - title: "Web Search", - description: providerLabel, - meta: [formatCount("source", sourceCount)], - }, + success + ? { + iconOverride: theme.styledSymbol("tool.webSearch", "accent"), + title: "Web Search", + description: providerLabel, + meta: [formatCount("source", sourceCount)], + } + : { + icon: "warning", + title: "Web Search", + description: providerLabel, + meta: [formatCount("source", sourceCount)], + }, theme, ); diff --git a/packages/coding-agent/test/acp-agent.test.ts b/packages/coding-agent/test/acp-agent.test.ts index 597c3c643..85d56324e 100644 --- a/packages/coding-agent/test/acp-agent.test.ts +++ b/packages/coding-agent/test/acp-agent.test.ts @@ -18,13 +18,17 @@ import { zSessionNotification, } from "@agentclientprotocol/sdk/dist/schema/zod.gen.js"; import type { Model } from "@oh-my-pi/pi-ai"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { + ACP_BOOTSTRAP_RACE_GUARD_MS, + AcpAgent, + createAcpExtensionUiContext, +} from "@oh-my-pi/pi-coding-agent/modes/acp/acp-agent"; +import type { PlanModeState } from "@oh-my-pi/pi-coding-agent/plan-mode/state"; +import type { AgentSession, AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { SILENT_ABORT_MARKER } from "@oh-my-pi/pi-coding-agent/session/messages"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils"; -import { resetSettingsForTest, Settings } from "../src/config/settings"; -import { ACP_BOOTSTRAP_RACE_GUARD_MS, AcpAgent, createAcpExtensionUiContext } from "../src/modes/acp/acp-agent"; -import type { PlanModeState } from "../src/plan-mode/state"; -import type { AgentSession, AgentSessionEvent } from "../src/session/agent-session"; -import { SILENT_ABORT_MARKER } from "../src/session/messages"; -import { SessionManager } from "../src/session/session-manager"; import { expectAcpStructure } from "./helpers/acp-schema"; const TEST_MODELS: Model[] = [ diff --git a/packages/coding-agent/test/acp-builtins.test.ts b/packages/coding-agent/test/acp-builtins.test.ts index 48f2112ed..9e35757cf 100644 --- a/packages/coding-agent/test/acp-builtins.test.ts +++ b/packages/coding-agent/test/acp-builtins.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, spyOn } from "bun:test"; -import { Settings } from "../src/config/settings"; -import type { AgentSession } from "../src/session/agent-session"; -import type { SessionManager } from "../src/session/session-manager"; -import { executeAcpBuiltinSlashCommand } from "../src/slash-commands/acp-builtins"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import type { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { executeAcpBuiltinSlashCommand } from "@oh-my-pi/pi-coding-agent/slash-commands/acp-builtins"; interface FakeAcpBuiltinSession { fastMode: boolean; @@ -689,7 +689,7 @@ describe("wave 5 — adapters and polish", () => { it("/mcp add foo --url https://example.com --token X --scope project: outputs success or propagates write error", async () => { // Uses project scope so it writes to /tmp/project/.omp/mcp.json which test infra controls. // We verify the command either reports success or a meaningful error (not a parse error). - const mcpModule = await import("../src/mcp/config-writer"); + const mcpModule = await import("@oh-my-pi/pi-coding-agent/mcp/config-writer"); const spy = spyOn(mcpModule, "addMCPServer").mockResolvedValue(undefined); try { const { output, runtime } = createRuntime(); @@ -727,7 +727,7 @@ describe("wave 5 — adapters and polish", () => { // /ssh add — spy on addSSHHost it("/ssh add foo --host x --user y --scope user: calls addSSHHost", async () => { - const sshModule = await import("../src/ssh/config-writer"); + const sshModule = await import("@oh-my-pi/pi-coding-agent/ssh/config-writer"); const spy = spyOn(sshModule, "addSSHHost").mockResolvedValue(undefined); try { const { output, runtime } = createRuntime(); @@ -835,7 +835,7 @@ describe("wave 5 — adapters and polish", () => { // /marketplace discover bulleted list it("/marketplace discover: output is bulleted with ' - ' token", async () => { - const { MarketplaceManager } = await import("../src/extensibility/plugins/marketplace"); + const { MarketplaceManager } = await import("@oh-my-pi/pi-coding-agent/extensibility/plugins/marketplace"); const discoverSpy = spyOn(MarketplaceManager.prototype, "listAvailablePlugins").mockResolvedValue([ { name: "hello", version: "1.0.0", description: "A greeting plugin" } as never, { name: "world", version: "2.0.0", description: undefined } as never, diff --git a/packages/coding-agent/test/acp-client-bridge.test.ts b/packages/coding-agent/test/acp-client-bridge.test.ts index 62946af23..4f86ea012 100644 --- a/packages/coding-agent/test/acp-client-bridge.test.ts +++ b/packages/coding-agent/test/acp-client-bridge.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import type { AgentSideConnection, RequestPermissionRequest } from "@agentclientprotocol/sdk"; -import { createAcpClientBridge } from "../src/modes/acp/acp-client-bridge"; +import { createAcpClientBridge } from "@oh-my-pi/pi-coding-agent/modes/acp/acp-client-bridge"; describe("ACP client bridge permission requests", () => { it("forwards pending tool-call status to session/request_permission", async () => { diff --git a/packages/coding-agent/test/acp-event-mapper.test.ts b/packages/coding-agent/test/acp-event-mapper.test.ts index c6c10e1cb..1cab03a00 100644 --- a/packages/coding-agent/test/acp-event-mapper.test.ts +++ b/packages/coding-agent/test/acp-event-mapper.test.ts @@ -5,14 +5,14 @@ import path from "node:path"; import type { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk"; import { zSessionNotification } from "@agentclientprotocol/sdk/dist/schema/zod.gen.js"; import type { Model } from "@oh-my-pi/pi-ai"; -import { AcpAgent } from "../src/modes/acp/acp-agent"; +import { AcpAgent } from "@oh-my-pi/pi-coding-agent/modes/acp/acp-agent"; import { buildToolCallStartUpdate, mapAgentSessionEventToAcpSessionUpdates, normalizeReplayToolArguments, -} from "../src/modes/acp/acp-event-mapper"; -import type { AgentSession, AgentSessionEvent } from "../src/session/agent-session"; -import { SessionManager } from "../src/session/session-manager"; +} from "@oh-my-pi/pi-coding-agent/modes/acp/acp-event-mapper"; +import type { AgentSession, AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { expectAcpStructure, expectAcpStructureRejects } from "./helpers/acp-schema"; function makeAssistantMessage(text: string) { diff --git a/packages/coding-agent/test/acp-initialize-conformance.test.ts b/packages/coding-agent/test/acp-initialize-conformance.test.ts index f70c386e5..eddd6bee6 100644 --- a/packages/coding-agent/test/acp-initialize-conformance.test.ts +++ b/packages/coding-agent/test/acp-initialize-conformance.test.ts @@ -10,11 +10,11 @@ import * as path from "node:path"; import type { AgentSideConnection, InitializeRequest } from "@agentclientprotocol/sdk"; import { zInitializeResponse } from "@agentclientprotocol/sdk/dist/schema/zod.gen.js"; import type { Model } from "@oh-my-pi/pi-ai"; +import { AcpAgent } from "@oh-my-pi/pi-coding-agent/modes/acp/acp-agent"; +import { ACP_TERMINAL_AUTH_FLAG, prepareAcpTerminalAuthArgs } from "@oh-my-pi/pi-coding-agent/modes/acp/terminal-auth"; +import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { getConfigRootDir, setAgentDir, VERSION } from "@oh-my-pi/pi-utils"; -import { AcpAgent } from "../src/modes/acp/acp-agent"; -import { ACP_TERMINAL_AUTH_FLAG, prepareAcpTerminalAuthArgs } from "../src/modes/acp/terminal-auth"; -import type { AgentSession } from "../src/session/agent-session"; -import { SessionManager } from "../src/session/session-manager"; import { expectAcpStructure } from "./helpers/acp-schema"; const TEST_MODELS: Model[] = [ diff --git a/packages/coding-agent/test/acp-lazy-startup.test.ts b/packages/coding-agent/test/acp-lazy-startup.test.ts index 2833ae51b..afdabaee0 100644 --- a/packages/coding-agent/test/acp-lazy-startup.test.ts +++ b/packages/coding-agent/test/acp-lazy-startup.test.ts @@ -11,12 +11,12 @@ import { type SessionNotification, } from "@agentclientprotocol/sdk"; import type { Model } from "@oh-my-pi/pi-ai"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { createAcpConnection } from "@oh-my-pi/pi-coding-agent/modes/acp/acp-mode"; +import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { Settings } from "../src/config/settings"; -import { createAcpConnection } from "../src/modes/acp/acp-mode"; -import type { AgentSession } from "../src/session/agent-session"; -import { AuthStorage } from "../src/session/auth-storage"; -import { SessionManager } from "../src/session/session-manager"; const TEST_MODEL: Model = { id: "claude-sonnet-4-20250514", @@ -156,7 +156,7 @@ async function closeTransport(writable: WritableStream): Promise describe("ACP lazy startup", () => { it("keeps ACP background jobs disabled by default and preserves explicit opt-ins", async () => { - const { runRootCommand } = await import("../src/main"); + const { runRootCommand } = await import("@oh-my-pi/pi-coding-agent/main"); type ObservedBackgroundSettings = { asyncEnabled: boolean; @@ -314,8 +314,8 @@ describe("ACP lazy startup", () => { const authStorage = await AuthStorage.create(path.join(cwd, "auth.db")); try { const settings = Settings.isolated({ "marketplace.autoUpdate": "off" }); - const { runRootCommand } = await import("../src/main"); - const { createAgentSession } = await import("../src/sdk"); + const { runRootCommand } = await import("@oh-my-pi/pi-coding-agent/main"); + const { createAgentSession } = await import("@oh-my-pi/pi-coding-agent/sdk"); let session: AgentSession | undefined; const stopped = runRootCommand( diff --git a/packages/coding-agent/test/acp-mcp-isolation.test.ts b/packages/coding-agent/test/acp-mcp-isolation.test.ts index 926bf53c3..abef4c4f2 100644 --- a/packages/coding-agent/test/acp-mcp-isolation.test.ts +++ b/packages/coding-agent/test/acp-mcp-isolation.test.ts @@ -16,13 +16,13 @@ import { 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 { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { createAcpSessionFactory } from "@oh-my-pi/pi-coding-agent/main"; +import type { CreateAgentSessionOptions, CreateAgentSessionResult } from "@oh-my-pi/pi-coding-agent/sdk"; +import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { Snowflake } from "@oh-my-pi/pi-utils"; -import { ModelRegistry } from "../src/config/model-registry"; -import { Settings } from "../src/config/settings"; -import { createAcpSessionFactory } from "../src/main"; -import type { CreateAgentSessionOptions, CreateAgentSessionResult } from "../src/sdk"; -import type { AgentSession } from "../src/session/agent-session"; -import { AuthStorage } from "../src/session/auth-storage"; describe("createAcpSessionFactory MCP isolation (issue #1234)", () => { it("forces enableMCP=false even when baseOptions opts in", async () => { diff --git a/packages/coding-agent/test/agent-session-mcp-discovery.test.ts b/packages/coding-agent/test/agent-session-mcp-discovery.test.ts index 4d3eed103..3c5d66ddf 100644 --- a/packages/coding-agent/test/agent-session-mcp-discovery.test.ts +++ b/packages/coding-agent/test/agent-session-mcp-discovery.test.ts @@ -4,11 +4,11 @@ import * as os from "node:os"; import * as path from "node:path"; import { Agent, type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { Effort, type Model } from "@oh-my-pi/pi-ai"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import type { CustomTool } from "@oh-my-pi/pi-coding-agent/extensibility/custom-tools/types"; +import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import * as z from "zod/v4"; -import { Settings } from "../src/config/settings"; -import type { CustomTool } from "../src/extensibility/custom-tools/types"; -import { AgentSession } from "../src/session/agent-session"; -import { SessionManager } from "../src/session/session-manager"; function createModel(): Model<"openai-responses"> { return { diff --git a/packages/coding-agent/test/agent-session-message-pipeline.test.ts b/packages/coding-agent/test/agent-session-message-pipeline.test.ts index 5ec856b57..8ac1f9b20 100644 --- a/packages/coding-agent/test/agent-session-message-pipeline.test.ts +++ b/packages/coding-agent/test/agent-session-message-pipeline.test.ts @@ -10,12 +10,7 @@ import { } from "@oh-my-pi/pi-ai"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { - AgentSession, - type AgentSessionEvent, - ANTHROPIC_TOOL_CALL_BATCH_CAP, - resolveToolCallBatchCapForModel, -} from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { convertToLlm, wrapSteeringForModel } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { createAssistantMessage } from "./helpers/agent-session-setup"; @@ -55,43 +50,6 @@ describe("AgentSession message pipeline", () => { } }); - it("enables the tool-call batch cap only for Anthropic Claude Opus 4.8 models", () => { - const baseModel: Model = { - id: "gpt-5", - name: "GPT-5", - api: "openai-responses", - provider: "openai", - baseUrl: "", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200_000, - maxTokens: 8_192, - }; - const anthropicOpus48: Model = { - ...baseModel, - id: "claude-opus-4-8", - name: "Claude Opus 4.8", - api: "anthropic", - provider: "anthropic", - }; - - expect(resolveToolCallBatchCapForModel(anthropicOpus48)).toBe(ANTHROPIC_TOOL_CALL_BATCH_CAP); - expect(resolveToolCallBatchCapForModel({ ...anthropicOpus48, id: "claude-opus-4.8" })).toBe( - ANTHROPIC_TOOL_CALL_BATCH_CAP, - ); - expect(resolveToolCallBatchCapForModel({ ...anthropicOpus48, id: "claude-opus-4-8-20260530" })).toBe( - ANTHROPIC_TOOL_CALL_BATCH_CAP, - ); - expect(resolveToolCallBatchCapForModel({ ...anthropicOpus48, provider: "openrouter" })).toBeUndefined(); - expect(resolveToolCallBatchCapForModel({ ...anthropicOpus48, id: "claude-sonnet-4-8" })).toBeUndefined(); - expect(resolveToolCallBatchCapForModel({ ...anthropicOpus48, id: "claude-opus-4-7" })).toBeUndefined(); - expect(resolveToolCallBatchCapForModel({ ...anthropicOpus48, id: "claude-opus-4-9" })).toBeUndefined(); - expect(resolveToolCallBatchCapForModel({ ...anthropicOpus48, id: "claude-opus-4-80" })).toBeUndefined(); - expect(resolveToolCallBatchCapForModel(baseModel)).toBeUndefined(); - expect(resolveToolCallBatchCapForModel({ ...baseModel, provider: "openai-codex" })).toBeUndefined(); - }); - it("applies transformContext before convertToLlm", async () => { const inputMessages: AgentMessage[] = [{ role: "user", content: "hello", timestamp: Date.now() }]; const transformedMessages: AgentMessage[] = [ diff --git a/packages/coding-agent/test/agent-session-role-thinking.test.ts b/packages/coding-agent/test/agent-session-role-thinking.test.ts index 19df05517..c7b002ff4 100644 --- a/packages/coding-agent/test/agent-session-role-thinking.test.ts +++ b/packages/coding-agent/test/agent-session-role-thinking.test.ts @@ -2,14 +2,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { Effort, getBundledModel } from "@oh-my-pi/pi-ai"; +import * as autoThinkingClassifier from "@oh-my-pi/pi-coding-agent/auto-thinking/classifier"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { + AUTO_THINKING, + clampAutoThinkingEffort, + resolveProvisionalAutoLevel, +} from "@oh-my-pi/pi-coding-agent/thinking"; import { TempDir } from "@oh-my-pi/pi-utils"; -import * as autoThinkingClassifier from "../src/auto-thinking/classifier"; -import { AUTO_THINKING, clampAutoThinkingEffort, resolveProvisionalAutoLevel } from "../src/thinking"; import { createAssistantMessage } from "./helpers/agent-session-setup"; describe("AgentSession role model thinking behavior", () => { diff --git a/packages/coding-agent/test/agent-session-ssh-refresh.test.ts b/packages/coding-agent/test/agent-session-ssh-refresh.test.ts index 663c57688..ed74523a1 100644 --- a/packages/coding-agent/test/agent-session-ssh-refresh.test.ts +++ b/packages/coding-agent/test/agent-session-ssh-refresh.test.ts @@ -1,16 +1,16 @@ import { afterEach, describe, expect, it, spyOn } from "bun:test"; import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; import type { Model } from "@oh-my-pi/pi-ai"; +import { reset as resetCapabilities } from "@oh-my-pi/pi-coding-agent/capability"; +import { type SSHHost, sshCapability } from "@oh-my-pi/pi-coding-agent/capability/ssh"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { loadCapability } from "@oh-my-pi/pi-coding-agent/discovery"; +import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { addSSHHost, removeSSHHost, updateSSHHost } from "@oh-my-pi/pi-coding-agent/ssh/config-writer"; +import * as connectionManager from "@oh-my-pi/pi-coding-agent/ssh/connection-manager"; +import { loadSshTool, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { getSSHConfigPath, TempDir } from "@oh-my-pi/pi-utils"; -import { reset as resetCapabilities } from "../src/capability"; -import { type SSHHost, sshCapability } from "../src/capability/ssh"; -import { Settings } from "../src/config/settings"; -import { loadCapability } from "../src/discovery"; -import { AgentSession } from "../src/session/agent-session"; -import { SessionManager } from "../src/session/session-manager"; -import { addSSHHost, removeSSHHost, updateSSHHost } from "../src/ssh/config-writer"; -import * as connectionManager from "../src/ssh/connection-manager"; -import { loadSshTool, type ToolSession } from "../src/tools"; function createModel(): Model<"openai-responses"> { return { diff --git a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts index 42b921a93..18e5066fb 100644 --- a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts +++ b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts @@ -1,11 +1,11 @@ import { afterEach, describe, expect, it, setSystemTime } from "bun:test"; import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; import type { Model } from "@oh-my-pi/pi-ai"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import type { CustomTool } from "@oh-my-pi/pi-coding-agent/extensibility/custom-tools/types"; +import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import * as z from "zod/v4"; -import { Settings } from "../src/config/settings"; -import type { CustomTool } from "../src/extensibility/custom-tools/types"; -import { AgentSession } from "../src/session/agent-session"; -import { SessionManager } from "../src/session/session-manager"; // Cache-stability invariant: when MCP servers reconnect with byte-identical tool // definitions, `refreshMCPTools` must not rebuild the system prompt. A rebuild diff --git a/packages/coding-agent/test/agent-storage-sqlite-compat.test.ts b/packages/coding-agent/test/agent-storage-sqlite-compat.test.ts index 83d946ebc..7bd59320b 100644 --- a/packages/coding-agent/test/agent-storage-sqlite-compat.test.ts +++ b/packages/coding-agent/test/agent-storage-sqlite-compat.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { AgentStorage } from "../src/session/agent-storage"; +import { AgentStorage } from "@oh-my-pi/pi-coding-agent/session/agent-storage"; import { readTableSql } from "./helpers/sqlite-inspect"; const LEGACY_TIMESTAMP = 1_700_000_000; diff --git a/packages/coding-agent/test/auth-broker-import.test.ts b/packages/coding-agent/test/auth-broker-import.test.ts index 877210325..8f21357bc 100644 --- a/packages/coding-agent/test/auth-broker-import.test.ts +++ b/packages/coding-agent/test/auth-broker-import.test.ts @@ -3,8 +3,8 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { type AuthBrokerServerHandle, AuthStorage, SqliteAuthCredentialStore, startAuthBroker } from "@oh-my-pi/pi-ai"; +import { runAuthBrokerCommand } from "@oh-my-pi/pi-coding-agent/cli/auth-broker-cli"; import { getAgentDbPath, setAgentDir } from "@oh-my-pi/pi-utils"; -import { runAuthBrokerCommand } from "../src/cli/auth-broker-cli"; const ORIGINAL_STDOUT_WRITE = process.stdout.write.bind(process.stdout); diff --git a/packages/coding-agent/test/auth-broker-snapshot-cache.test.ts b/packages/coding-agent/test/auth-broker-snapshot-cache.test.ts index 7dba4027c..510f1b94c 100644 --- a/packages/coding-agent/test/auth-broker-snapshot-cache.test.ts +++ b/packages/coding-agent/test/auth-broker-snapshot-cache.test.ts @@ -3,12 +3,12 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { type AuthBrokerServerHandle, AuthStorage, SqliteAuthCredentialStore, startAuthBroker } from "@oh-my-pi/pi-ai"; -import { discoverAuthStorage } from "../src/sdk"; +import { discoverAuthStorage } from "@oh-my-pi/pi-coding-agent/sdk"; import { readAuthBrokerSnapshotCache, type SnapshotResponse, writeAuthBrokerSnapshotCache, -} from "../src/session/auth-storage"; +} from "@oh-my-pi/pi-coding-agent/session/auth-storage"; const ENV_KEYS = [ "OMP_AUTH_BROKER_URL", diff --git a/packages/coding-agent/test/auth-storage-rotation.test.ts b/packages/coding-agent/test/auth-storage-rotation.test.ts index 32dcafbd9..823cf557c 100644 --- a/packages/coding-agent/test/auth-storage-rotation.test.ts +++ b/packages/coding-agent/test/auth-storage-rotation.test.ts @@ -3,8 +3,8 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { UsageProvider } from "@oh-my-pi/pi-ai"; -import * as oauth from "@oh-my-pi/pi-ai/utils/oauth"; -import type { OAuthCredentials } from "@oh-my-pi/pi-ai/utils/oauth/types"; +import * as oauth from "@oh-my-pi/pi-ai/oauth"; +import type { OAuthCredentials } from "@oh-my-pi/pi-ai/oauth/types"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { Snowflake } from "@oh-my-pi/pi-utils"; diff --git a/packages/coding-agent/test/auto-thinking-classifier.test.ts b/packages/coding-agent/test/auto-thinking-classifier.test.ts index e472420c2..8d712ef3c 100644 --- a/packages/coding-agent/test/auto-thinking-classifier.test.ts +++ b/packages/coding-agent/test/auto-thinking-classifier.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from "bun:test"; import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { Effort, getBundledModel } from "@oh-my-pi/pi-ai"; +import { parseDifficultyBucket, parseDifficultyLevel } from "@oh-my-pi/pi-coding-agent/auto-thinking/classifier"; import { AUTO_THINKING, clampAutoThinkingEffort, parseConfiguredThinkingLevel, parseThinkingLevel, } from "@oh-my-pi/pi-coding-agent/thinking"; -import { parseDifficultyBucket, parseDifficultyLevel } from "../src/auto-thinking/classifier"; describe("auto thinking classifier helpers", () => { it("parses configured thinking without widening provider-facing thinking selectors", () => { diff --git a/packages/coding-agent/test/autoresearch-state.test.ts b/packages/coding-agent/test/autoresearch-state.test.ts index d44a02fbc..08f3dcb71 100644 --- a/packages/coding-agent/test/autoresearch-state.test.ts +++ b/packages/coding-agent/test/autoresearch-state.test.ts @@ -2,8 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { Snowflake } from "@oh-my-pi/pi-utils"; -import { createAutoresearchExtension } from "../src/autoresearch/index"; +import { createAutoresearchExtension } from "@oh-my-pi/pi-coding-agent/autoresearch"; import { buildExperimentState, computeConfidence, @@ -11,11 +10,16 @@ import { findBaselineRunNumber, findBestKeptMetric, reconstructControlState, -} from "../src/autoresearch/state"; -import { AutoresearchStorage } from "../src/autoresearch/storage"; -import type { ExperimentResult } from "../src/autoresearch/types"; -import type { ExtensionAPI, ExtensionCommandContext, RegisteredCommand } from "../src/extensibility/extensions"; -import * as git from "../src/utils/git"; +} from "@oh-my-pi/pi-coding-agent/autoresearch/state"; +import { AutoresearchStorage } from "@oh-my-pi/pi-coding-agent/autoresearch/storage"; +import type { ExperimentResult } from "@oh-my-pi/pi-coding-agent/autoresearch/types"; +import type { + ExtensionAPI, + ExtensionCommandContext, + RegisteredCommand, +} from "@oh-my-pi/pi-coding-agent/extensibility/extensions"; +import * as git from "@oh-my-pi/pi-coding-agent/utils/git"; +import { Snowflake } from "@oh-my-pi/pi-utils"; afterEach(() => { vi.restoreAllMocks(); diff --git a/packages/coding-agent/test/autoresearch-tools.test.ts b/packages/coding-agent/test/autoresearch-tools.test.ts index 32b2ce59a..7bc7d34b7 100644 --- a/packages/coding-agent/test/autoresearch-tools.test.ts +++ b/packages/coding-agent/test/autoresearch-tools.test.ts @@ -3,16 +3,16 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai"; +import { createSessionRuntime } from "@oh-my-pi/pi-coding-agent/autoresearch/state"; +import { openAutoresearchStorage } from "@oh-my-pi/pi-coding-agent/autoresearch/storage"; +import { createInitExperimentTool } from "@oh-my-pi/pi-coding-agent/autoresearch/tools/init-experiment"; +import { createLogExperimentTool } from "@oh-my-pi/pi-coding-agent/autoresearch/tools/log-experiment"; +import { createRunExperimentTool } from "@oh-my-pi/pi-coding-agent/autoresearch/tools/run-experiment"; +import { createUpdateNotesTool } from "@oh-my-pi/pi-coding-agent/autoresearch/tools/update-notes"; +import type { LogDetails, RunDetails } from "@oh-my-pi/pi-coding-agent/autoresearch/types"; +import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent/extensibility/extensions"; import { Snowflake } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; -import { createSessionRuntime } from "../src/autoresearch/state"; -import { openAutoresearchStorage } from "../src/autoresearch/storage"; -import { createInitExperimentTool } from "../src/autoresearch/tools/init-experiment"; -import { createLogExperimentTool } from "../src/autoresearch/tools/log-experiment"; -import { createRunExperimentTool } from "../src/autoresearch/tools/run-experiment"; -import { createUpdateNotesTool } from "../src/autoresearch/tools/update-notes"; -import type { LogDetails, RunDetails } from "../src/autoresearch/types"; -import type { ExtensionAPI, ExtensionContext } from "../src/extensibility/extensions"; afterEach(() => { vi.restoreAllMocks(); diff --git a/packages/coding-agent/test/bash-acp-terminal.test.ts b/packages/coding-agent/test/bash-acp-terminal.test.ts index 0323dec17..b9aad742f 100644 --- a/packages/coding-agent/test/bash-acp-terminal.test.ts +++ b/packages/coding-agent/test/bash-acp-terminal.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; -import type { ClientBridge, ClientBridgeTerminalHandle } from "../src/session/client-bridge"; -import type { ToolSession } from "../src/tools"; -import { BashTool } from "../src/tools/bash"; +import type { ClientBridge, ClientBridgeTerminalHandle } from "@oh-my-pi/pi-coding-agent/session/client-bridge"; +import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import { BashTool } from "@oh-my-pi/pi-coding-agent/tools/bash"; function makeSession(bridge: ClientBridge): ToolSession { return { diff --git a/packages/coding-agent/test/bash-failure-result.test.ts b/packages/coding-agent/test/bash-failure-result.test.ts index b75be6c90..0e8523ebd 100644 --- a/packages/coding-agent/test/bash-failure-result.test.ts +++ b/packages/coding-agent/test/bash-failure-result.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import type { ToolSession } from "../src/tools"; -import { BashTool } from "../src/tools/bash"; +import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import { BashTool } from "@oh-my-pi/pi-coding-agent/tools/bash"; function makeSession(): ToolSession { return { diff --git a/packages/coding-agent/test/capability/rule-buckets.test.ts b/packages/coding-agent/test/capability/rule-buckets.test.ts index c2b1a6a6c..dfcd6d31a 100644 --- a/packages/coding-agent/test/capability/rule-buckets.test.ts +++ b/packages/coding-agent/test/capability/rule-buckets.test.ts @@ -16,6 +16,7 @@ function makeRule(partial: Partial): Rule { alwaysApply: partial.alwaysApply, description: partial.description, condition: partial.condition, + astCondition: partial.astCondition, scope: partial.scope, interruptMode: partial.interruptMode, _source: partial._source ?? source("native"), @@ -34,6 +35,18 @@ describe("bucketRules", () => { expect(mgr.checkDelta("contains FORBIDDEN token", { source: "text" }).map(r => r.name)).toEqual(["no-foo"]); }); + it("registers an ast-only rule as TTSR and excludes it from rulebook/always buckets", () => { + const mgr = new TtsrManager(); + const ttsr = makeRule({ name: "no-console", astCondition: ["console.log($A)"], description: "blocks console" }); + + const { rulebookRules, alwaysApplyRules } = bucketRules([ttsr], mgr); + + expect(rulebookRules).toHaveLength(0); + expect(alwaysApplyRules).toHaveLength(0); + expect(mgr.hasRules()).toBe(true); + expect(mgr.hasAstRules()).toBe(true); + }); + it("splits non-TTSR rules into always-apply and rulebook by metadata", () => { const mgr = new TtsrManager(); const sticky = makeRule({ name: "sticky", alwaysApply: true, description: "sticky desc" }); diff --git a/packages/coding-agent/test/checkpoint-rpc-qa.ts b/packages/coding-agent/test/checkpoint-rpc-qa.ts index 277569196..ad65599a2 100644 --- a/packages/coding-agent/test/checkpoint-rpc-qa.ts +++ b/packages/coding-agent/test/checkpoint-rpc-qa.ts @@ -2,13 +2,13 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { AgentEvent, AgentMessage } from "@oh-my-pi/pi-agent-core"; -import { RpcClient } from "../src/modes/rpc/rpc-client"; +import { RpcClient } from "@oh-my-pi/pi-coding-agent/modes/rpc/rpc-client"; import { type BranchSummaryEntry, type CustomMessageEntry, parseSessionEntries, type SessionMessageEntry, -} from "../src/session/session-manager"; +} from "@oh-my-pi/pi-coding-agent/session/session-manager"; function extractText(message: AgentMessage): string { if (message.role !== "assistant") return ""; diff --git a/packages/coding-agent/test/cli-cwd-flag.test.ts b/packages/coding-agent/test/cli-cwd-flag.test.ts index 0f131f153..c61ac34e4 100644 --- a/packages/coding-agent/test/cli-cwd-flag.test.ts +++ b/packages/coding-agent/test/cli-cwd-flag.test.ts @@ -2,9 +2,9 @@ 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 { parseArgs } from "@oh-my-pi/pi-coding-agent/cli/args"; +import { applyStartupCwd } from "@oh-my-pi/pi-coding-agent/cli/startup-cwd"; import { getProjectDir, normalizePathForComparison, setProjectDir } from "@oh-my-pi/pi-utils"; -import { parseArgs } from "../src/cli/args"; -import { applyStartupCwd } from "../src/cli/startup-cwd"; const originalProjectDir = getProjectDir(); diff --git a/packages/coding-agent/test/cli-hide-thinking-flag.test.ts b/packages/coding-agent/test/cli-hide-thinking-flag.test.ts index 4b4dcf949..a58634a1c 100644 --- a/packages/coding-agent/test/cli-hide-thinking-flag.test.ts +++ b/packages/coding-agent/test/cli-hide-thinking-flag.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import { Effort } from "@oh-my-pi/pi-ai"; -import { parseArgs } from "../src/cli/args"; +import { parseArgs } from "@oh-my-pi/pi-coding-agent/cli/args"; describe("parseArgs — --hide-thinking flag", () => { it("parses --hide-thinking as a boolean flag", () => { diff --git a/packages/coding-agent/test/cli/completions.test.ts b/packages/coding-agent/test/cli/completions.test.ts index 491279d85..2dcf3a17c 100644 --- a/packages/coding-agent/test/cli/completions.test.ts +++ b/packages/coding-agent/test/cli/completions.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; import * as path from "node:path"; +import { buildSpec, type CompletionSpec, generateCompletion } from "@oh-my-pi/pi-coding-agent/cli/completion-gen"; import type { CliConfig, CommandCtor } from "@oh-my-pi/pi-utils/cli"; -import { buildSpec, type CompletionSpec, generateCompletion } from "../../src/cli/completion-gen"; const repoRoot = path.resolve(import.meta.dir, "..", "..", "..", ".."); const cliEntry = path.join(repoRoot, "packages", "coding-agent", "src", "cli.ts"); diff --git a/packages/coding-agent/test/client-prompts.test.ts b/packages/coding-agent/test/client-prompts.test.ts index b0c272f67..a21b47986 100644 --- a/packages/coding-agent/test/client-prompts.test.ts +++ b/packages/coding-agent/test/client-prompts.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { getPrompt, listPrompts, serverSupportsPrompts } from "../src/mcp/client"; -import type { MCPGetPromptResult, MCPPrompt, MCPPromptsListResult } from "../src/mcp/types"; +import { getPrompt, listPrompts, serverSupportsPrompts } from "@oh-my-pi/pi-coding-agent/mcp/client"; +import type { MCPGetPromptResult, MCPPrompt, MCPPromptsListResult } from "@oh-my-pi/pi-coding-agent/mcp/types"; import { createMockConnection, createMockTransport } from "./mcp-test-utils"; describe("listPrompts", () => { diff --git a/packages/coding-agent/test/client-resources.test.ts b/packages/coding-agent/test/client-resources.test.ts index 72f2d647e..d3ac976fb 100644 --- a/packages/coding-agent/test/client-resources.test.ts +++ b/packages/coding-agent/test/client-resources.test.ts @@ -7,7 +7,7 @@ import { serverSupportsResources, subscribeToResources, unsubscribeFromResources, -} from "../src/mcp/client"; +} from "@oh-my-pi/pi-coding-agent/mcp/client"; import type { MCPResource, MCPResourceReadResult, @@ -15,7 +15,7 @@ import type { MCPResourceTemplate, MCPResourceTemplatesListResult, MCPTransport, -} from "../src/mcp/types"; +} from "@oh-my-pi/pi-coding-agent/mcp/types"; import { createMockConnection, createMockTransport } from "./mcp-test-utils"; describe("listResources", () => { diff --git a/packages/coding-agent/test/commit-agentic-attribution.test.ts b/packages/coding-agent/test/commit-agentic-attribution.test.ts index 6b16b8246..820c7cbb8 100644 --- a/packages/coding-agent/test/commit-agentic-attribution.test.ts +++ b/packages/coding-agent/test/commit-agentic-attribution.test.ts @@ -1,11 +1,11 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import { getBundledModel } from "@oh-my-pi/pi-ai"; -import { runCommitAgentSession } from "../src/commit/agentic/agent"; -import * as toolsModule from "../src/commit/agentic/tools"; -import { Settings } from "../src/config/settings"; -import type { CreateAgentSessionResult } from "../src/sdk"; -import * as sdkModule from "../src/sdk"; -import type { PromptOptions } from "../src/session/agent-session"; +import { runCommitAgentSession } from "@oh-my-pi/pi-coding-agent/commit/agentic/agent"; +import * as toolsModule from "@oh-my-pi/pi-coding-agent/commit/agentic/tools"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import type { CreateAgentSessionResult } from "@oh-my-pi/pi-coding-agent/sdk"; +import * as sdkModule from "@oh-my-pi/pi-coding-agent/sdk"; +import type { PromptOptions } from "@oh-my-pi/pi-coding-agent/session/agent-session"; describe("commit agent prompt attribution", () => { afterEach(() => { diff --git a/packages/coding-agent/test/commit-command-exit.test.ts b/packages/coding-agent/test/commit-command-exit.test.ts index 5b2a88dc6..f75247049 100644 --- a/packages/coding-agent/test/commit-command-exit.test.ts +++ b/packages/coding-agent/test/commit-command-exit.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; +import CommitCommand from "@oh-my-pi/pi-coding-agent/commands/commit"; +import * as commitModule from "@oh-my-pi/pi-coding-agent/commit"; +import * as themeModule from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { postmortem } from "@oh-my-pi/pi-utils"; -import CommitCommand from "../src/commands/commit"; -import * as commitModule from "../src/commit"; -import * as themeModule from "../src/modes/theme/theme"; describe("omp commit command lifecycle (issue #1041)", () => { afterEach(() => { diff --git a/packages/coding-agent/test/commit-model-selection-role-thinking.test.ts b/packages/coding-agent/test/commit-model-selection-role-thinking.test.ts index f5cffc8f5..498ce011d 100644 --- a/packages/coding-agent/test/commit-model-selection-role-thinking.test.ts +++ b/packages/coding-agent/test/commit-model-selection-role-thinking.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import { Effort, getBundledModel } from "@oh-my-pi/pi-ai"; -import { resolvePrimaryModel, resolveSmolModel } from "../src/commit/model-selection"; +import { resolvePrimaryModel, resolveSmolModel } from "@oh-my-pi/pi-coding-agent/commit/model-selection"; function getModelOrThrow(id: string) { const model = getBundledModel("anthropic", id); diff --git a/packages/coding-agent/test/commit-shared-llm.test.ts b/packages/coding-agent/test/commit-shared-llm.test.ts index f30f54f9f..64be9b8f1 100644 --- a/packages/coding-agent/test/commit-shared-llm.test.ts +++ b/packages/coding-agent/test/commit-shared-llm.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "bun:test"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; -import { createConventionalAnalysisTool, parseConventionalAnalysisResponse } from "../src/commit/shared-llm"; +import { + createConventionalAnalysisTool, + parseConventionalAnalysisResponse, +} from "@oh-my-pi/pi-coding-agent/commit/shared-llm"; describe("commit shared LLM parsing", () => { it("ignores harmless extra fields in conventional analysis tool output", () => { diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index 453a51a84..1b733df5d 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -15,7 +15,6 @@ import * as ai from "@oh-my-pi/pi-ai"; import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { encodeTextSignatureV1 } from "@oh-my-pi/pi-ai/providers/openai-responses-shared"; import type { AssistantMessage, Model, ProviderPayload, Usage } from "@oh-my-pi/pi-ai/types"; -import { hookFetch } from "@oh-my-pi/pi-utils"; import { buildSessionContext, type CompactionEntry, @@ -25,7 +24,8 @@ import { type SessionEntry, type SessionMessageEntry, type ThinkingLevelChangeEntry, -} from "../src/session/session-manager"; +} from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { hookFetch } from "@oh-my-pi/pi-utils"; import { e2eApiKey } from "./utilities"; // ============================================================================ diff --git a/packages/coding-agent/test/config-cli.test.ts b/packages/coding-agent/test/config-cli.test.ts index 62cba399a..bcd7f7b96 100644 --- a/packages/coding-agent/test/config-cli.test.ts +++ b/packages/coding-agent/test/config-cli.test.ts @@ -2,9 +2,9 @@ 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 { runConfigCommand } from "@oh-my-pi/pi-coding-agent/cli/config-cli"; +import { resetSettingsForTest } from "@oh-my-pi/pi-coding-agent/config/settings"; import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils"; -import { runConfigCommand } from "../src/cli/config-cli"; -import { resetSettingsForTest } from "../src/config/settings"; let testAgentDir = ""; const originalAgentDir = process.env.PI_CODING_AGENT_DIR; diff --git a/packages/coding-agent/test/core/eval-workflow-helpers.integration.test.ts b/packages/coding-agent/test/core/eval-workflow-helpers.integration.test.ts index 7635576de..12954ff84 100644 --- a/packages/coding-agent/test/core/eval-workflow-helpers.integration.test.ts +++ b/packages/coding-agent/test/core/eval-workflow-helpers.integration.test.ts @@ -5,7 +5,9 @@ * Gated by `PI_PYTHON_INTEGRATION=1` so CI without a real Python interpreter * (or sandboxes where subprocess spawning is restricted) does not fail. */ + import { afterEach, describe, expect, it } from "bun:test"; +import * as path from "node:path"; import { disposeAllKernelSessions, executePythonWithKernel } from "@oh-my-pi/pi-coding-agent/eval/py/executor"; import { PythonKernel } from "@oh-my-pi/pi-coding-agent/eval/py/kernel"; import { TempDir } from "@oh-my-pi/pi-utils"; @@ -102,4 +104,27 @@ describe.skipIf(!SHOULD_RUN)("python eval workflow helpers", () => { await kernel.shutdown(); } }); + + it("local:// helpers resolve under the injected root", async () => { + using tempDir = TempDir.createSync("@eval-workflow-local-roots-"); + const root = path.join(tempDir.path(), "artifacts", "local"); + const kernel = await PythonKernel.start({ cwd: tempDir.path() }); + try { + const code = [ + "p = write('local://notes/merge-map.md', 'hello')", + "print('WROTE', str(p))", + "append('local://notes/merge-map.md', ' world')", + "print('READ', read('local://notes/merge-map.md'))", + ].join("\n"); + const result = await executePythonWithKernel(kernel, code, { localRoots: { local: root } }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain(`WROTE ${path.join(root, "notes", "merge-map.md")}`); + expect(result.output).toContain("READ hello world"); + // Lands under the injected root — not a literal `local:` directory under cwd. + expect(await Bun.file(path.join(root, "notes", "merge-map.md")).text()).toBe("hello world"); + expect(await Bun.file(path.join(tempDir.path(), "local:")).exists()).toBe(false); + } finally { + await kernel.shutdown(); + } + }); }); diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index 2f82d6e84..24c3036d7 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -3,85 +3,20 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { - applyEdits, - buildCompactDiffPreview as buildCompactHashlineDiffPreview, - detectLineEnding, - type Edit, - InMemorySnapshotStore as FileReadCache, - Filesystem, + type InMemorySnapshotStore as FileReadCache, formatHashlineHeader, MismatchError as HashlineMismatchError, - NotFoundError, - Patch, - Patcher, - type PatchSection, - parsePatch as parseHashline, - Recovery, - type SplitOptions, - type WriteResult, } from "@oh-my-pi/hashline"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { canonicalSnapshotKey, type ExecuteHashlineSingleOptions, executeHashlineSingle, - generateDiffString, getFileSnapshotStore as getFileReadCache, hashlineEditParamsSchema, } from "@oh-my-pi/pi-coding-agent/edit"; -import * as z from "zod/v4"; - -/** - * The test bodies use a small adapter over the package API so production code - * can use the package names directly while assertions stay compact. - */ -function applyHashlineEdits( - text: string, - edits: readonly Edit[], -): { - text: string; - lines: string; - firstChangedLine?: number; - warnings?: string[]; -} { - const r = applyEdits(text, [...edits]); - return { ...r, lines: r.text }; -} - -interface SectionView { - path: string; - fileHash?: string; - diff: string; -} -function toSectionView(section: PatchSection): SectionView { - return section.fileHash !== undefined - ? { path: section.path, fileHash: section.fileHash, diff: section.diff } - : { path: section.path, diff: section.diff }; -} -function splitHashlineInput(input: string, options: SplitOptions = {}): SectionView { - return toSectionView(Patch.parseSingle(input, options)); -} -function splitHashlineInputs(input: string, options: SplitOptions = {}): SectionView[] { - return Patch.parse(input, options).sections.map(toSectionView); -} - -function tryRecoverHashlineWithCache(args: { - cache: FileReadCache; - absolutePath: string; - currentText: string; - tag: string; - edits: readonly Edit[]; -}): { text: string; lines: string; firstChangedLine: number | undefined; warnings: string[] } | null { - const recovered = new Recovery(args.cache).tryRecover({ - path: args.absolutePath, - currentText: args.currentText, - fileHash: args.tag, - edits: args.edits, - }); - return recovered ? { ...recovered, lines: recovered.text } : null; -} - import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import * as z from "zod/v4"; beforeAll(async () => { resetSettingsForTest(); @@ -89,8 +24,6 @@ beforeAll(async () => { }); const repl = (text: string): string => `+${text}`; -const outputSep = ":"; -const outputSepRe = ":"; function tag(line: number, _content: string): string { return `${line}`; @@ -106,9 +39,6 @@ function recordFullSnapshot(cache: FileReadCache, filePath: string, fullText: st function snapshotHead(cache: FileReadCache, filePath: string) { return cache.head(canonicalSnapshotKey(filePath)); } -function snapshotByHash(cache: FileReadCache, filePath: string, hash: string) { - return cache.byHash(canonicalSnapshotKey(filePath), hash); -} function header(filePath: string, tag: string): string { return formatHashlineHeader(filePath, tag); @@ -118,10 +48,6 @@ function sameLineRange(anchor: string): string { return `replace ${anchor}..${anchor}:`; } -function applyDiff(content: string, diff: string): string { - return applyHashlineEdits(content, parseHashline(diff).edits).lines; -} - async function withTempDir(fn: (tempDir: string) => Promise): Promise { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hashline-edit-")); try { @@ -156,488 +82,6 @@ function hashlineExecuteOptions( }; } -class PolicyFilesystem extends Filesystem { - #files = new Map(); - #blocked = new Set(); - - constructor(initial: Iterable, blocked: Iterable) { - super(); - for (const [filePath, content] of initial) this.#files.set(filePath, content); - for (const filePath of blocked) this.#blocked.add(filePath); - } - - async readText(filePath: string): Promise { - const content = this.#files.get(filePath); - if (content === undefined) throw new NotFoundError(filePath); - return content; - } - - async preflightWrite(filePath: string): Promise { - if (this.#blocked.has(filePath)) throw new Error(`blocked write: ${filePath}`); - } - - async writeText(filePath: string, content: string): Promise { - this.#files.set(filePath, content); - return { text: content }; - } - - get(filePath: string): string | undefined { - return this.#files.get(filePath); - } -} - -describe("hashline normalization", () => { - it("preserves the first newline style when restoring mixed-ending files", () => { - expect(detectLineEnding("a\r\nb\nc")).toBe("\r\n"); - expect(detectLineEnding("a\nb\r\nc")).toBe("\n"); - }); -}); - -describe("hashline parser — range-anchor syntax", () => { - it("keeps parsed edits reusable across different target snapshots", () => { - const section = Patch.parseSingle(["[a.ts]", `insert after ${tag(2, "bbb")}:`, repl("tail")].join("\n")); - - expect(section.applyTo("aaa\nbbb").text).toBe("aaa\nbbb\ntail"); - expect(section.applyTo("aaa\nbbb\nccc").text).toBe("aaa\nbbb\ntail\nccc"); - }); - - const content = "aaa\nbbb\nccc"; - - it("inserts payload before/after a Lid, and at insert head:/insert tail:", () => { - const diff = [ - `insert before ${tag(2, "bbb")}:`, - repl("before b"), - `insert after ${tag(2, "bbb")}:`, - repl("after b"), - "insert head:", - repl("top"), - "insert tail:", - repl("tail"), - ].join("\n"); - expect(applyDiff(content, diff)).toBe("top\naaa\nbefore b\nbbb\nafter b\nccc\ntail"); - }); - - it("inserts after the final line without falling off the file", () => { - const diff = [`insert after ${tag(3, "ccc")}:`, repl("tail")].join("\n"); - expect(applyDiff(content, diff)).toBe("aaa\nbbb\nccc\ntail"); - }); - - it("deletes a line or range via delete hunks", () => { - expect(applyDiff(content, `delete ${tag(2, "bbb")}`)).toBe("aaa\nccc"); - expect(applyDiff(content, `delete ${tag(2, "bbb")}..${tag(3, "ccc")}`)).toBe("aaa"); - }); - - it("replaces a line with one blank when given an explicit empty replace payload", () => { - const explicit = [`${sameLineRange(tag(2, "bbb"))}`, repl("")].join("\n"); - expect(applyDiff(content, explicit)).toBe("aaa\n\nccc"); - }); - - it("replaces one line or an inclusive range with payload lines", () => { - const single = [`${sameLineRange(tag(2, "bbb"))}`, repl("BBB")].join("\n"); - expect(applyDiff(content, single)).toBe("aaa\nBBB\nccc"); - - const range = [`replace ${tag(2, "bbb")}..${tag(3, "ccc")}:`, repl("BBB"), repl("CCC")].join("\n"); - expect(applyDiff(content, range)).toBe("aaa\nBBB\nCCC"); - }); - - it("rejects bare single-number hunk headers", () => { - const anchor = tag(2, "bbb"); - expect(() => parseHashline(`${anchor}\n${repl("BBB")}`)).toThrow(/hunk headers need a verb/); - }); - - it("delete hunk deletes the range entirely", () => { - const anchor = tag(2, "bbb"); - expect(applyDiff(content, `delete ${anchor}`)).toBe("aaa\nccc"); - expect(applyDiff(content, `delete ${anchor}..${tag(3, "ccc")}`)).toBe("aaa"); - }); - - it("rejects orphan inline-anchor shapes from old format", () => { - const anchor = tag(2, "bbb"); - for (const diff of [`${anchor}..${tag(3, "ccc")}=NEW`, "insert head:=NEW", "insert tail:=NEW"]) { - expect(() => parseHashline(diff)).toThrow(/payload line has no preceding hunk header/); - } - }); - - it("emits body rows in textual order", () => { - const diff = [ - `${sameLineRange(tag(2, "bbb"))}`, - repl("above 1"), - repl("above 2"), - repl("BBB"), - repl("below 1"), - repl("below 2"), - ].join("\n"); - expect(applyDiff(content, diff)).toBe("aaa\nabove 1\nabove 2\nBBB\nbelow 1\nbelow 2\nccc"); - }); - - it("inserts around an anchor with explicit insert hunks", () => { - const diff = [ - `insert before ${tag(2, "bbb")}:`, - repl("before"), - `insert after ${tag(2, "bbb")}:`, - repl("after"), - ].join("\n"); - expect(applyDiff(content, diff)).toBe("aaa\nbefore\nbbb\nafter\nccc"); - }); - - it("escapes literal leading payload sigils with literal rows", () => { - // `+` is the canonical sigil; payload rows like `+|literal` emit - // `|literal` verbatim. Same for `^literal` and `↓literal` — none of - // these are recognized sigils once they sit inside a `+TEXT` row. - const diff = [`${sameLineRange(tag(2, "bbb"))}`, repl("|literal"), repl("^literal"), repl("↓literal")].join("\n"); - expect(applyDiff(content, diff)).toBe("aaa\n|literal\n^literal\n↓literal\nccc"); - }); - - it("accepts literal payload at virtual insert head:/insert tail: anchors", () => { - expect(applyDiff(content, ["insert head:", repl("HEAD")].join("\n"))).toBe("HEAD\naaa\nbbb\nccc"); - expect(applyDiff(content, ["insert tail:", repl("TAIL")].join("\n"))).toBe("aaa\nbbb\nccc\nTAIL"); - }); - - it("auto-pipes unprefixed payload continuation lines as literal text", () => { - const anchor = tag(2, "bbb"); - const { edits, warnings } = parseHashline(`${sameLineRange(anchor)}\n${repl("FIRST")}\nSECOND`); - expect(applyHashlineEdits("aaa\nbbb\nccc", edits).lines).toBe("aaa\nFIRST\nSECOND\nccc"); - expect(warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true); - }); - - it("preserves whitespace-bearing payload exactly", () => { - const anchor = tag(2, "bbb"); - const payload = "\tconst streamKeepaliveMs = opts.streamKeepaliveMs;"; - expect(applyDiff(content, [`insert after ${anchor}:`, repl(payload)].join("\n"))).toBe( - `aaa\nbbb\n${payload}\nccc`, - ); - expect(applyDiff(content, [`insert before ${anchor}:`, repl(payload)].join("\n"))).toBe( - `aaa\n${payload}\nbbb\nccc`, - ); - }); - - it("keeps duplicated multiline replacement boundaries literal", () => { - const prefixSource = ["// one", "// two", "old();"].join("\n"); - const prefixDiff = [`${sameLineRange(tag(3, "old();"))}`, repl("// one"), repl("// two"), repl("new();")].join( - "\n", - ); - expect(applyDiff(prefixSource, prefixDiff)).toBe(["// one", "// two", "// one", "// two", "new();"].join("\n")); - - const suffixSource = ["old();", "// one", "// two"].join("\n"); - const suffixDiff = [`${sameLineRange(tag(1, "old();"))}`, repl("new();"), repl("// one"), repl("// two")].join( - "\n", - ); - expect(applyDiff(suffixSource, suffixDiff)).toBe(["new();", "// one", "// two", "// one", "// two"].join("\n")); - }); - - it("de-duplicates structural replacement boundaries (balance-validated)", () => { - // `replace 1..1:` replaces `old();` but the payload also restates the `};` that - // survives at line 2 — a duplicate close that would unbalance braces. - const suffixSource = ["old();", "};"].join("\n"); - const suffixDiff = [`${sameLineRange(tag(1, "old();"))}`, repl("new();"), repl("};")].join("\n"); - expect(applyDiff(suffixSource, suffixDiff)).toBe(["new();", "};"].join("\n")); - - // Mirror case at the leading edge. - const prefixSource = ["};", "old();"].join("\n"); - const prefixDiff = [`${sameLineRange(tag(2, "old();"))}`, repl("};"), repl("new();")].join("\n"); - expect(applyDiff(prefixSource, prefixDiff)).toBe(["};", "new();"].join("\n")); - - const result = applyHashlineEdits(suffixSource, parseHashline(suffixDiff).edits); - expect(result.warnings?.some(w => /delimiter-balance/.test(w))).toBe(true); - }); - - it("keeps duplicated single non-structural replacement boundaries literal", () => { - const prefixSource = ["const X = …", "", "const LEGACY = {", " a: 1,", "}"].join("\n"); - const prefixDiff = [`replace ${tag(2, "")}..${tag(5, "}")}:`, repl("const X = …")].join("\n"); - expect(applyDiff(prefixSource, prefixDiff)).toBe(["const X = …", "const X = …"].join("\n")); - - const suffixSource = ["## Legacy", "", "stale content", "", "## Subagents"].join("\n"); - const suffixDiff = [`replace ${tag(1, "## Legacy")}..${tag(4, "")}:`, repl("## Subagents")].join("\n"); - expect(applyDiff(suffixSource, suffixDiff)).toBe(["## Subagents", "## Subagents"].join("\n")); - }); - - it("does not emit warnings for duplicated replacement boundaries", () => { - const source = ["// one", "// two", "old();"].join("\n"); - const diff = [`${sameLineRange(tag(3, "old();"))}`, repl("// one"), repl("// two"), repl("new();")].join("\n"); - - const result = applyHashlineEdits(source, parseHashline(diff).edits); - expect(result.lines).toBe(["// one", "// two", "// one", "// two", "new();"].join("\n")); - expect(result.warnings).toBeUndefined(); - }); - - it("preserves a legitimate single-line replacement that happens to match an adjacent line", () => { - const source = ["foo", "bar", "baz"].join("\n"); - const diff = [`${sameLineRange(tag(2, "bar"))}`, repl("foo")].join("\n"); - - expect(applyDiff(source, diff)).toBe(["foo", "foo", "baz"].join("\n")); - }); - - it("keeps pure-insert payload that duplicates adjacent file context", () => { - const eofSource = ["aaa", "bbb", "ccc"].join("\n"); - const eofDiff = ["insert tail:", repl("bbb"), repl("ccc"), repl("NEW")].join("\n"); - expect(applyDiff(eofSource, eofDiff)).toBe("aaa\nbbb\nccc\nbbb\nccc\nNEW"); - - const bofSource = ["aaa", "bbb", "ccc", "ddd"].join("\n"); - const bofDiff = ["insert head:", repl("NEW"), repl("aaa"), repl("bbb")].join("\n"); - expect(applyDiff(bofSource, bofDiff)).toBe("NEW\naaa\nbbb\naaa\nbbb\nccc\nddd"); - }); - - it("preserves duplicated structural pure-insert payload", () => { - const source = ["if ok {", " keep();", " }"].join("\n"); - const diff = ["insert tail:", repl(" added();"), repl(" }")].join("\n"); - - expect(applyDiff(source, diff)).toBe(["if ok {", " keep();", " }", " added();", " }"].join("\n")); - }); - - it("preserves an intentional non-structural duplicate for after insert", () => { - const source = ["aaa", "bbb", "ccc"].join("\n"); - const diff = [`insert after ${tag(2, "bbb")}:`, repl("bbb"), repl("NEW")].join("\n"); - - expect(applyDiff(source, diff)).toBe("aaa\nbbb\nbbb\nNEW\nccc"); - }); - - it("preserves an intentional non-structural duplicate for before insert", () => { - const source = ["aaa", "bbb", "ccc"].join("\n"); - const diff = [`insert before ${tag(2, "bbb")}:`, repl("NEW"), repl("bbb")].join("\n"); - - expect(applyDiff(source, diff)).toBe("aaa\nNEW\nbbb\nbbb\nccc"); - }); - - it("keeps a single structural pure-insert suffix when it preserves balance", () => { - const source = ["if outer {", "}"].join("\n"); - const diff = [`insert before ${tag(2, "}")}:`, repl("if inner {"), repl("}")].join("\n"); - - expect(applyDiff(source, diff)).toBe(["if outer {", "if inner {", "}", "}"].join("\n")); - }); - - it("preserves payload text exactly", () => { - const diff = [ - `${sameLineRange(tag(2, "bbb"))}`, - repl(""), - repl("# not a header"), - repl("+ not an op"), - repl("\\ not an op"), - repl(" spaced"), - ].join("\n"); - expect(applyDiff(content, diff)).toBe("aaa\n\n# not a header\n+ not an op\n\\ not an op\n spaced\nccc"); - }); - - it("treats explicit empty replace payload rows as blank lines", () => { - const diff = [`${sameLineRange(tag(2, "bbb"))}`, repl("first"), repl(""), repl(""), repl("after")].join("\n"); - expect(applyDiff(content, diff)).toBe("aaa\nfirst\n\n\nafter\nccc"); - }); - - it("skips markdown-comment lines immediately before an operation", () => { - const diff = [ - "# This is a comment line from a model explanation.", - "## Another comment line.", - `${sameLineRange(tag(2, "bbb"))}`, - repl("BBB"), - ].join("\n"); - expect(applyDiff(content, diff)).toBe("aaa\nBBB\nccc"); - }); - - it("does not skip comment lines when they are not immediately before an operation", () => { - const diff = ["# This is a stray comment.", "", `${sameLineRange(tag(2, "bbb"))}`, repl("BBB")].join("\n"); - expect(() => parseHashline(diff)).toThrow(/payload line has no preceding/); - }); - - it("preserves raw blank separators between ops", () => { - const diff = [ - `${sameLineRange(tag(1, "aaa"))}`, - repl("AAA"), - "", - "", - `${sameLineRange(tag(3, "ccc"))}`, - repl("CCC"), - ].join("\n"); - expect(applyDiff(content, diff)).toBe("AAA\nbbb\nCCC"); - }); - - it("inserts explicit blank lines above and below an anchor", () => { - expect(applyDiff(content, `insert before ${tag(1, "aaa")}:\n${repl("")}`)).toBe("\naaa\nbbb\nccc"); - expect(applyDiff(content, `insert after ${tag(1, "aaa")}:\n${repl("")}`)).toBe("aaa\n\nbbb\nccc"); - }); - - it("rejects orphan payload lines with no preceding op", () => { - expect(() => parseHashline(repl("orphan")).edits).toThrow(/payload line has no preceding/); - }); - - it("treats empty replace bodies as deletes", () => { - expect(parseHashline(`${sameLineRange(tag(2, "bbb"))}`).edits).toEqual([ - { kind: "delete", anchor: { line: 2 }, lineNum: 1, index: 0 }, - ]); - expect(parseHashline(`delete ${tag(2, "bbb")}`).edits).toEqual([ - { kind: "delete", anchor: { line: 2 }, lineNum: 1, index: 0 }, - ]); - }); - - it("rejects `LINE:TEXT` copied verbatim from read output", () => { - const anchor = tag(2, "bbb"); - expect(() => parseHashline(`${sameLineRange(anchor)}:BBB`)).toThrow(/payload line has no preceding hunk header/); - expect(() => parseHashline(`${anchor}..${tag(3, "ccc")}:BBB`)).toThrow( - /payload line has no preceding hunk header/, - ); - }); - - it("rejects arrow replace syntax as an unrecognized payload line", () => { - expect(() => parseHashline(`2→\nBBB`).edits).toThrow(/payload line has no preceding/); - expect(() => parseHashline(`2-3→\nBBB`).edits).toThrow(/payload line has no preceding/); - }); - - it("preserves payload text containing arrow sigils after the leading payload sigil", () => { - const anchor = tag(2, "bbb"); - expect(applyDiff(content, `${sameLineRange(anchor)}\n${repl("bbb↑")}\n${repl("tail↓")}`)).toBe( - "aaa\nbbb↑\ntail↓\nccc", - ); - }); - - it("accepts insert head:/insert tail: inserts with literal payload rows", () => { - expect(applyDiff(content, `insert head:\n${repl("HEAD")}`)).toBe("HEAD\naaa\nbbb\nccc"); - expect(applyDiff(content, `insert tail:\n${repl("TAIL")}`)).toBe("aaa\nbbb\nccc\nTAIL"); - }); - - it("rejects two replace ops targeting the same single line", () => { - const diff = `${sameLineRange(tag(2, "bbb"))}\n${repl("BBB")}\n${sameLineRange(tag(2, "bbb"))}\n${repl("BBB2")}`; - expect(() => parseHashline(diff).edits).toThrow(/anchor line 2 is already targeted/); - }); - - it("rejects two replace ops covering the same range", () => { - const diff = `replace ${tag(2, "bbb")}..${tag(3, "ccc")}:\n${repl("OLD")}\n${repl("OLD2")}\nreplace ${tag(2, "bbb")}..${tag(3, "ccc")}:\n${repl("NEW")}\n${repl("NEW2")}`; - expect(() => parseHashline(diff).edits).toThrow(/anchor line 2 is already targeted/); - }); - - it("still rejects two replace ops whose ranges partially overlap without containment", () => { - // 3-5 extends past the outer 2-4, so it is neither identical nor contained. - // The inner anchors still clash with the outer range's deletes and the - // post-hoc validator catches the overlap. - const diff = `replace ${tag(2, "bbb")}..${tag(4, "ddd")}:\n${repl("NEW1")}\nreplace ${tag(3, "ccc")}..${tag(5, "eee")}:\n${repl("NEW2")}`; - expect(() => parseHashline(diff).edits).toThrow(/anchor line 3 is already targeted by another hunk on line 1/); - }); - - it("uses `|` payload lines inside a multi-line replacement", () => { - const diff = `replace ${tag(2, "bbb")}..${tag(4, "ddd")}:\n${repl("line one")}\n${repl("line two")}\n${repl("line three")}`; - const { edits, warnings } = parseHashline(diff); - expect(applyHashlineEdits("aaa\nbbb\nccc\nddd\neee", edits).lines).toBe( - "aaa\nline one\nline two\nline three\neee", - ); - expect(warnings).toEqual([]); - }); - - it("strips read-output `N:` line-number prefix from auto-piped bare body rows", () => { - const diff = `replace ${tag(2, "bbb")}..${tag(4, "ddd")}:\n${repl("line one")}\n${tag(3, "ccc")}:line two`; - const { edits, warnings } = parseHashline(diff); - expect(applyHashlineEdits("aaa\nbbb\nccc\nddd\neee", edits).lines).toBe("aaa\nline one\nline two\neee"); - expect(warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true); - }); - - it("treats `N:` outside the pending range as a separate op", () => { - const diff = `replace ${tag(2, "bbb")}..${tag(3, "ccc")}:\n${repl("line one")}\n${sameLineRange(tag(5, "eee"))}\n${repl("line five")}`; - const { edits, warnings } = parseHashline(diff); - expect(applyHashlineEdits("aaa\nbbb\nccc\nddd\neee\nfff", edits).lines).toBe( - "aaa\nline one\nddd\nline five\nfff", - ); - expect(warnings).toEqual([]); - }); - - it("accepts multiple literal rows before an anchor", () => { - const diff = `insert before ${tag(2, "bbb")}:\n${repl("X")}\n${repl("Y")}`; - expect(applyDiff(content, diff)).toBe("aaa\nX\nY\nbbb\nccc"); - }); - - it("accepts a replace alongside surrounding literal rows", () => { - const diff = `${sameLineRange(tag(2, "bbb"))}\n${repl("ABOVE")}\n${repl("NEW")}`; - expect(applyDiff(content, diff)).toBe("aaa\nABOVE\nNEW\nccc"); - }); -}); - -describe("hashline — snapshot tag binding", () => { - it("rejects line-hash anchors as unrecognized payload lines", () => { - expect(() => parseHashline(`2ab:\n${repl("BBB")}`).edits).toThrow(/payload line has no preceding/); - }); - - it("applies line-number edits without per-anchor hash validation", () => { - const diff = `${sameLineRange(tag(2, "bbb"))}\n${repl("BBB")}`; - expect(applyDiff("aaa\nbbb\nccc", diff)).toBe("aaa\nBBB\nccc"); - }); -}); - -describe("splitHashlineInput — bracket headers", () => { - it("extracts path, snapshot tag, and diff body from [path#tag] header", () => { - const input = [`[src/foo.ts#1A2B]`, `${sameLineRange(tag(2, "bbb"))}`, repl("BBB")].join("\n"); - expect(splitHashlineInput(input)).toEqual({ - path: "src/foo.ts", - fileHash: "1A2B", - diff: `${sameLineRange(tag(2, "bbb"))}\n${repl("BBB")}`, - }); - }); - - it("strips leading blank lines", () => { - expect(splitHashlineInput(`\n[foo.ts]\ninsert head:\n${repl("x")}`)).toEqual({ - path: "foo.ts", - diff: `insert head:\n${repl("x")}`, - }); - }); - - it("normalizes cwd-prefixed absolute paths to cwd-relative paths", () => { - const cwd = process.cwd(); - const absolute = path.join(cwd, "src", "foo.ts"); - expect(splitHashlineInput(`[${absolute}]\ninsert head:\n${repl("x")}`, { cwd }).path).toBe("src/foo.ts"); - }); - - it("uses explicit fallback path only when input has recognizable operations", () => { - expect(splitHashlineInput(`insert head:\n${repl("x")}`, { path: "a.ts" })).toEqual({ - path: "a.ts", - diff: `insert head:\n${repl("x")}`, - }); - expect(() => splitHashlineInput("plain text", { path: "a.ts" })).toThrow(/must begin with/); - }); - - it("splits multiple edit sections", () => { - const input = ["[a.ts]", "insert head:", repl("a"), "[b.ts]", "insert tail:", repl("b")].join("\n"); - expect(splitHashlineInputs(input)).toEqual([ - { path: "a.ts", diff: `insert head:\n${repl("a")}` }, - { path: "b.ts", diff: `insert tail:\n${repl("b")}` }, - ]); - }); - it("rejects a unified-diff hunk header on the first line as contamination", () => { - const input = ["@@ -1,3 +1,3 @@", "insert head:", repl("x")].join("\n"); - expect(() => splitHashlineInputs(input)).toThrow(/unified-diff hunk header/); - }); - - it("rejects a unified-diff hunk header (`-N,M +N,M`)", () => { - const input = ["@@ -1,3 +1,3 @@", "insert head:", repl("x")].join("\n"); - expect(() => splitHashlineInputs(input)).toThrow(/unified-diff hunk header/); - }); - - it("silently drops a trailing header with no operations", () => { - const input = ["[a.ts]", "insert head:", repl("a"), "[b.ts]"].join("\n"); - expect(splitHashlineInputs(input)).toEqual([{ path: "a.ts", diff: `insert head:\n${repl("a")}` }]); - }); -}); - -it("preflights write policy for every section before committing a batch", async () => { - const fixture = new PolicyFilesystem( - [ - ["a.ts", "aaa\n"], - ["b.ts", "bbb\n"], - ], - ["b.ts"], - ); - const snapshots = new FileReadCache(); - const aTag = recordFullSnapshot(snapshots, "a.ts", "aaa\n"); - const bTag = recordFullSnapshot(snapshots, "b.ts", "bbb\n"); - const input = [ - header("a.ts", aTag), - `${sameLineRange(tag(1, "aaa"))}`, - repl("AAA"), - header("b.ts", bTag), - `${sameLineRange(tag(1, "bbb"))}`, - repl("BBB"), - ].join("\n"); - - await expect(new Patcher({ fs: fixture, snapshots }).apply(Patch.parse(input))).rejects.toThrow( - /blocked write: b\.ts/, - ); - expect(fixture.get("a.ts")).toBe("aaa\n"); - expect(fixture.get("b.ts")).toBe("bbb\n"); -}); - describe("hashline executor", () => { it("rejects file creation and directs to the write tool", async () => { await withTempDir(async tempDir => { @@ -821,43 +265,6 @@ describe("hashlineEditParamsSchema — payload shape", () => { }); }); -describe("buildCompactHashlineDiffPreview — line numbers track post-edit positions", () => { - it("emits context lines against the new file's line numbers after a range expansion", () => { - const before = ["a1", "a2", "a3", "a4", "a5", "a6", "a7"].join("\n"); - const after = ["a1", "a2", "a3", "X", "Y", "Z", "a5", "a6", "a7"].join("\n"); - const { diff } = generateDiffString(before, after); - const preview = buildCompactHashlineDiffPreview(diff); - - // Walk the preview and verify every ` LINE:content` line matches what - // the file now has at that line number. - const newFileLines = after.split("\n"); - for (const line of preview.preview.split("\n")) { - if (!line.startsWith(" ")) continue; - // Skip context-elision markers ("...") which carry no real file content. - if (line.endsWith(`${outputSep}...`)) continue; - const match = new RegExp(`^\\s(\\d+)${outputSepRe}(.*)$`).exec(line); - expect(match).not.toBeNull(); - if (!match) continue; - const lineNum = Number(match[1]); - const content = match[2]; - expect(newFileLines[lineNum - 1]).toBe(content); - } - }); - - it("emits + and - lines with bare line numbers", () => { - const before = "alpha\nbeta\ngamma\n"; - const after = "alpha\nDELTA\nEPSILON\ngamma\n"; - const { diff } = generateDiffString(before, after); - const preview = buildCompactHashlineDiffPreview(diff); - - const additions = preview.preview.split("\n").filter(line => line.startsWith("+")); - expect(additions).toEqual([`+2${outputSep}DELTA`, `+3${outputSep}EPSILON`]); - - const removals = preview.preview.split("\n").filter(line => line.startsWith("-")); - expect(removals).toEqual([`-2${outputSep}beta`]); - }); -}); - describe("hashline — anchor-stale recovery via read snapshot cache", () => { it("recovers when the file was modified out-of-band after a read", async () => { await withTempDir(async tempDir => { @@ -921,36 +328,6 @@ describe("hashline — anchor-stale recovery via read snapshot cache", () => { }); }); - it("returns null from tryRecoverHashlineWithCache when applyPatch cannot land", () => { - const cache = new FileReadCache(); - const fakePath = "/tmp/__hashline-recovery-applypatch__.ts"; - const snapshotText = "alpha\nbeta\ngamma\ndelta\nepsilon"; - const snapshotTag = recordFullSnapshot(cache, fakePath, snapshotText); - - // Live file is completely different — patch context cannot match even - // with fuzz tolerance. - const currentText = "totally\nunrelated\ncontent\nhere\nnow\n"; - const edits = parseHashline(`${sameLineRange(tag(2, "beta"))}\n${repl("BETA-MODEL")}`).edits; - - const recovered = tryRecoverHashlineWithCache({ - cache, - absolutePath: fakePath, - currentText, - edits, - tag: snapshotTag, - }); - expect(recovered).toBeNull(); - }); - - it("isolates caches across sessions", () => { - const a = new FileReadCache(); - const b = new FileReadCache(); - const fakePath = "/tmp/__hashline-cache-isolation__.ts"; - a.record(fakePath, "x\ny\nz\n"); - expect(a.head(fakePath)).not.toBeNull(); - expect(b.head(fakePath)).toBeNull(); - }); - it("captures the post-edit result so the next edit can recover from anchors against it", async () => { await withTempDir(async tempDir => { const filePath = path.join(tempDir, "a.ts"); @@ -1022,164 +399,4 @@ describe("hashline — anchor-stale recovery via read snapshot cache", () => { expect(await Bun.file(filePath).text()).toBe(`${v1Lines.join("\n")}\n`); }); }); - - it("recovers from an older in-session snapshot even if the current file advanced again", () => { - const cache = new FileReadCache(); - const fakePath = "/tmp/__hashline-cache-ring-recovery__.ts"; - const v0Text = "L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10\n"; - const v1Text = "L1\nL2-EDITED\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10\n"; - const currentText = "L1\nL2-EDITED\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10\nTRAILER\n"; - - const v0Tag = recordFullSnapshot(cache, fakePath, v0Text); - recordFullSnapshot(cache, fakePath, v1Text); - - const recovered = tryRecoverHashlineWithCache({ - cache, - absolutePath: canonicalSnapshotKey(fakePath), - currentText, - tag: v0Tag, - edits: parseHashline(`replace 10..10:\n${repl("L10-EDITED")}`).edits, - }); - - expect(recovered).not.toBeNull(); - expect(recovered?.lines).toContain("L10-EDITED"); - }); - - it("retains older versions per path so stale tags still resolve", () => { - const cache = new FileReadCache(); - const fakePath = "/tmp/__hashline-cache-history__.ts"; - const oneTag = recordFullSnapshot(cache, fakePath, "one\n"); - const twoTag = recordFullSnapshot(cache, fakePath, "two\n"); - recordFullSnapshot(cache, fakePath, "three\n"); - expect(snapshotHead(cache, fakePath)?.text).toBe("three\n"); - expect(snapshotByHash(cache, fakePath, oneTag)?.text).toBe("one\n"); - expect(snapshotByHash(cache, fakePath, twoTag)?.text).toBe("two\n"); - }); - it("evicts the least-recently-used path beyond the LRU cap", () => { - const cache = new FileReadCache({ maxPaths: 4 }); - for (let i = 0; i < 6; i++) { - recordFullSnapshot(cache, `/tmp/file-${i}.ts`, `x${i}\n`); - } - // The two oldest paths aged out; the four most-recent survive. - expect(snapshotHead(cache, "/tmp/file-0.ts")).toBeNull(); - expect(snapshotHead(cache, "/tmp/file-1.ts")).toBeNull(); - expect(snapshotHead(cache, "/tmp/file-2.ts")?.text).toBe("x2\n"); - expect(snapshotHead(cache, "/tmp/file-5.ts")?.text).toBe("x5\n"); - }); -}); - -describe("hashline *** Abort recovery sentinel (harmony-leak mitigation)", () => { - const sentinel = "*** Abort"; - - it("parser breaks at *** Abort silently (no warning)", () => { - const diff = [ - `insert after ${tag(1, "alpha")}:`, - repl("HELLO"), - sentinel, - `insert after ${tag(99, "junk")}:`, - repl("never"), - ].join("\n"); - const { edits, warnings } = parseHashline(diff); - expect(edits).toHaveLength(1); - expect(edits[0]).toMatchObject({ kind: "insert", text: "HELLO" }); - // The "*** Abort" marker terminates parsing but no longer surfaces a - // warning: by the time the marker arrives the stream is already gone - // and the prior wording ("truncated mid-call") was speculative. - expect(warnings).toEqual([]); - }); - - it("inserted sentinel from harmony-leak truncation: ops above are preserved", () => { - // Mirrors the exact shape harmony-leak emits inside a single section. - const diff = `insert after ${tag(1, "alpha")}:\n${repl("KEPT")}\n*** Abort\n`; - const { edits, warnings } = parseHashline(diff); - expect(edits).toHaveLength(1); - expect(edits[0]).toMatchObject({ text: "KEPT" }); - expect(warnings).toEqual([]); - }); - - it("splitter respects *** Abort like *** End Patch", () => { - const input = [ - `[a.ts]`, - `insert after ${tag(1, "alpha")}:`, - repl("a-payload"), - sentinel, - `[b.ts]`, - `insert after ${tag(1, "beta")}:`, - repl("never-emitted"), - ].join("\n"); - const sections = splitHashlineInputs(input); - expect(sections).toHaveLength(1); - expect(sections[0].path).toBe("a.ts"); - expect(sections[0].diff.includes("never-emitted")).toBe(false); - }); - - it("clean input without sentinel produces no warning", () => { - const diff = `insert after ${tag(1, "alpha")}:\n${repl("PAYLOAD")}\n`; - const { warnings } = parseHashline(diff); - expect(warnings).toEqual([]); - }); -}); - -describe("hashline parser — delete and empty-block semantics", () => { - it("inline delete deletes a single line", () => { - const text = "line1\nline2\nline3\n"; - const { diff } = splitHashlineInput(`[a.ts]\ndelete 2\n`); - expect(applyDiff(text, diff)).toBe("line1\nline3\n"); - }); - - it("inline delete deletes the range", () => { - const text = "line1\nline2\nline3\nline4\n"; - const { diff } = splitHashlineInput(`[a.ts]\ndelete 2..3\n`); - expect(applyDiff(text, diff)).toBe("line1\nline4\n"); - }); - - it("empty replace removes the range", () => { - const text = "line1\nline2\nline3\n"; - const { diff } = splitHashlineInput(`[a.ts]\nreplace 2..2:\n`); - expect(applyDiff(text, diff)).toBe("line1\nline3\n"); - }); - - it("`2..2=replacement` (old format) parses as orphan body, not as inline payload", () => { - const { diff } = splitHashlineInput(`[a.ts]\n2..2=replacement\n`); - expect(() => parseHashline(diff)).toThrow(/payload line has no preceding hunk header/); - }); - - it("explicit empty literal rows insert blank lines when the anchor is repeated", () => { - const text = "line1\nline2\nline3\n"; - const aboveDiff = splitHashlineInput(`[a.ts]\ninsert before 2:\n${repl("")}\n`).diff; - expect(applyDiff(text, aboveDiff)).toBe("line1\n\nline2\nline3\n"); - - const belowDiff = splitHashlineInput(`[a.ts]\ninsert after 2:\n${repl("")}\n`).diff; - expect(applyDiff(text, belowDiff)).toBe("line1\nline2\n\nline3\n"); - }); -}); - -describe("hashline parser — explicit blank payload rows", () => { - it("raw blank lines between ops are ignored", () => { - const text = "a\nb\nc\nd\ne\n"; - const ops = `[a.ts]\nreplace 1..1:\n${repl("A")}\n\nreplace 3..3:\n${repl("C")}\n`; - const { diff } = splitHashlineInput(ops); - expect(applyDiff(text, diff)).toBe("A\nb\nC\nd\ne\n"); - }); - - it("empty replacement payload rows are appended as blank payload lines", () => { - const text = "a\nb\nc\nd\ne\n"; - const ops = `[a.ts]\nreplace 1..1:\n${repl("A")}\n${repl("")}\n${repl("")}\nreplace 3..3:\n${repl("C")}\n`; - const { diff } = splitHashlineInput(ops); - expect(applyDiff(text, diff)).toBe("A\n\n\nb\nC\nd\ne\n"); - }); - - it("`replace N..N:` followed by two empty replace rows replaces the line with two blanks", () => { - const text = "a\nb\nc\nd\ne\n"; - const ops = `[a.ts]\nreplace 2..2:\n${repl("")}\n${repl("")}\nreplace 4..4:\n${repl("D")}\n`; - const { diff } = splitHashlineInput(ops); - expect(applyDiff(text, diff)).toBe("a\n\n\nc\nD\ne\n"); - }); - - it("empty replace row inside payload between two content lines is preserved", () => { - const text = "a\nb\nc\n"; - const ops = `[a.ts]\nreplace 2..2:\n${repl("first")}\n${repl("")}\n${repl("second")}\n`; - const { diff } = splitHashlineInput(ops); - expect(applyDiff(text, diff)).toBe("a\nfirst\n\nsecond\nc\n"); - }); }); diff --git a/packages/coding-agent/test/core/js-executor.test.ts b/packages/coding-agent/test/core/js-executor.test.ts index 7776bfbb2..c8757c7c7 100644 --- a/packages/coding-agent/test/core/js-executor.test.ts +++ b/packages/coding-agent/test/core/js-executor.test.ts @@ -2,11 +2,11 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, setDefaultTimeout import * as path from "node:path"; import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { disposeAllVmContexts } from "@oh-my-pi/pi-coding-agent/eval/js/context-manager"; +import { executeJs, type JsResult } from "@oh-my-pi/pi-coding-agent/eval/js/executor"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { TempDir } from "@oh-my-pi/pi-utils"; import * as z from "zod/v4"; -import { disposeAllVmContexts } from "../../src/eval/js/context-manager"; -import { executeJs, type JsResult } from "../../src/eval/js/executor"; // JS eval cold-starts a Bun worker; under --isolate + high CI concurrency that startup // can exceed Bun's 5s default per-test timeout, flaking the suite. Give the worker-backed diff --git a/packages/coding-agent/test/core/js-static-import-rewrite.test.ts b/packages/coding-agent/test/core/js-static-import-rewrite.test.ts index 1bb41310d..4771caee4 100644 --- a/packages/coding-agent/test/core/js-static-import-rewrite.test.ts +++ b/packages/coding-agent/test/core/js-static-import-rewrite.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { rewriteImports, wrapCode } from "../../src/eval/js/context-manager"; +import { rewriteImports, wrapCode } from "@oh-my-pi/pi-coding-agent/eval/js/context-manager"; // Test fixtures embed user-supplied `import(...)` syntax that the rewriter must // transform. The strings are split so static-analysis heuristics don't read them diff --git a/packages/coding-agent/test/core/js-tool-bridge.test.ts b/packages/coding-agent/test/core/js-tool-bridge.test.ts index 811463622..9b4b747b6 100644 --- a/packages/coding-agent/test/core/js-tool-bridge.test.ts +++ b/packages/coding-agent/test/core/js-tool-bridge.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, vi } from "bun:test"; import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { callSessionTool } from "@oh-my-pi/pi-coding-agent/eval/js/tool-bridge"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import * as z from "zod/v4"; -import { callSessionTool } from "../../src/eval/js/tool-bridge"; function createTool( name: string, diff --git a/packages/coding-agent/test/core/js-workflow-helpers.test.ts b/packages/coding-agent/test/core/js-workflow-helpers.test.ts index d8592165a..9beb785b9 100644 --- a/packages/coding-agent/test/core/js-workflow-helpers.test.ts +++ b/packages/coding-agent/test/core/js-workflow-helpers.test.ts @@ -1,10 +1,10 @@ import { afterAll, beforeAll, describe, expect, it, setDefaultTimeout } from "bun:test"; import * as path from "node:path"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { disposeAllVmContexts } from "@oh-my-pi/pi-coding-agent/eval/js/context-manager"; +import { executeJs, type JsResult } from "@oh-my-pi/pi-coding-agent/eval/js/executor"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { disposeAllVmContexts } from "../../src/eval/js/context-manager"; -import { executeJs, type JsResult } from "../../src/eval/js/executor"; // JS eval cold-starts a Bun worker; under --isolate + high CI concurrency that startup // can exceed Bun's 5s default per-test timeout, flaking the suite. Give the worker-backed diff --git a/packages/coding-agent/test/custom-editor-keybindings.test.ts b/packages/coding-agent/test/custom-editor-keybindings.test.ts index 04647e4bf..9009f86c2 100644 --- a/packages/coding-agent/test/custom-editor-keybindings.test.ts +++ b/packages/coding-agent/test/custom-editor-keybindings.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "bun:test"; +import { CustomEditor, extractBracketedImagePastePath } from "@oh-my-pi/pi-coding-agent/modes/components/custom-editor"; import { defaultEditorTheme } from "../../tui/test/test-themes"; -import { CustomEditor, extractBracketedImagePastePath } from "../src/modes/components/custom-editor"; function ctrl(key: string): string { return String.fromCharCode(key.toLowerCase().charCodeAt(0) & 31); diff --git a/packages/coding-agent/test/dap-write-sink-flush.typecheck.ts b/packages/coding-agent/test/dap-write-sink-flush.typecheck.ts index 08efa692e..02bffbb19 100644 --- a/packages/coding-agent/test/dap-write-sink-flush.typecheck.ts +++ b/packages/coding-agent/test/dap-write-sink-flush.typecheck.ts @@ -1,4 +1,4 @@ -import type { DapClient } from "../src/dap/client"; +import type { DapClient } from "@oh-my-pi/pi-coding-agent/dap/client"; // Type-only import forces standard TypeScript to check src/dap/client.ts, // including the socketToSink() implementation against DapWriteSink.flush(). diff --git a/packages/coding-agent/test/debug/dap-launch-failures.test.ts b/packages/coding-agent/test/debug/dap-launch-failures.test.ts index 9d5802860..5ec7abaab 100644 --- a/packages/coding-agent/test/debug/dap-launch-failures.test.ts +++ b/packages/coding-agent/test/debug/dap-launch-failures.test.ts @@ -2,13 +2,18 @@ import { afterEach, describe, expect, it, spyOn, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { Settings } from "../../src/config/settings"; -import * as dapModule from "../../src/dap"; -import { DapClient } from "../../src/dap/client"; -import { DapSessionManager } from "../../src/dap/session"; -import type { DapCapabilities, DapClientState, DapEventMessage, DapResolvedAdapter } from "../../src/dap/types"; -import type { ToolSession } from "../../src/tools"; -import { DebugTool } from "../../src/tools/debug"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import * as dapModule from "@oh-my-pi/pi-coding-agent/dap"; +import { DapClient } from "@oh-my-pi/pi-coding-agent/dap/client"; +import { DapSessionManager } from "@oh-my-pi/pi-coding-agent/dap/session"; +import type { + DapCapabilities, + DapClientState, + DapEventMessage, + DapResolvedAdapter, +} from "@oh-my-pi/pi-coding-agent/dap/types"; +import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import { DebugTool } from "@oh-my-pi/pi-coding-agent/tools/debug"; const TEST_ADAPTER: DapResolvedAdapter = { name: "lldb-dap", diff --git a/packages/coding-agent/test/debug/log-formatting.test.ts b/packages/coding-agent/test/debug/log-formatting.test.ts index c0df00f5f..76d4997e9 100644 --- a/packages/coding-agent/test/debug/log-formatting.test.ts +++ b/packages/coding-agent/test/debug/log-formatting.test.ts @@ -3,7 +3,7 @@ import { formatDebugLogExpandedLines, formatDebugLogLine, parseDebugLogTimestampMs, -} from "../../src/debug/log-formatting"; +} from "@oh-my-pi/pi-coding-agent/debug/log-formatting"; describe("formatDebugLogLine", () => { it("strips ANSI codes and carriage returns", () => { diff --git a/packages/coding-agent/test/debug/log-viewer.test.ts b/packages/coding-agent/test/debug/log-viewer.test.ts index 16bce1360..d32b5de96 100644 --- a/packages/coding-agent/test/debug/log-viewer.test.ts +++ b/packages/coding-agent/test/debug/log-viewer.test.ts @@ -4,7 +4,7 @@ import { DebugLogViewerModel, LOAD_OLDER_LABEL, SESSION_BOUNDARY_WARNING, -} from "../../src/debug/log-viewer"; +} from "@oh-my-pi/pi-coding-agent/debug/log-viewer"; describe("DebugLogViewerModel", () => { const describeRow = (row: { kind: string; logIndex?: number }): string => { diff --git a/packages/coding-agent/test/debug/protocol-probe.test.ts b/packages/coding-agent/test/debug/protocol-probe.test.ts index 20bea256d..2eabac540 100644 --- a/packages/coding-agent/test/debug/protocol-probe.test.ts +++ b/packages/coding-agent/test/debug/protocol-probe.test.ts @@ -1,12 +1,12 @@ import { afterEach, beforeAll, describe, expect, it } from "bun:test"; -import { getImageDimensions, ImageBudget, ImageProtocol, TERMINAL } from "@oh-my-pi/pi-tui"; import { buildLargeTextLines, buildSampleImage, encodeRgbPng, ProtocolProbeComponent, -} from "../../src/debug/protocol-probe"; -import { initTheme } from "../../src/modes/theme/theme"; +} from "@oh-my-pi/pi-coding-agent/debug/protocol-probe"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { getImageDimensions, ImageBudget, ImageProtocol, TERMINAL } from "@oh-my-pi/pi-tui"; beforeAll(async () => { // buildLargeTextLines styles the OSC 66 span through the global theme singleton. diff --git a/packages/coding-agent/test/debug/raw-sse-buffer.test.ts b/packages/coding-agent/test/debug/raw-sse-buffer.test.ts index 56008eab7..510810c35 100644 --- a/packages/coding-agent/test/debug/raw-sse-buffer.test.ts +++ b/packages/coding-agent/test/debug/raw-sse-buffer.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "bun:test"; import type { Model } from "@oh-my-pi/pi-ai"; -import { RawSseDebugBuffer, rawSseRecordLines, resolveRawSseDebugBuffer } from "../../src/debug/raw-sse-buffer"; +import { + RawSseDebugBuffer, + rawSseRecordLines, + resolveRawSseDebugBuffer, +} from "@oh-my-pi/pi-coding-agent/debug/raw-sse-buffer"; const model: Model<"anthropic-messages"> = { id: "claude-test", diff --git a/packages/coding-agent/test/debug/raw-sse-pretty.test.ts b/packages/coding-agent/test/debug/raw-sse-pretty.test.ts index 2dc12e11e..5a29c0b8e 100644 --- a/packages/coding-agent/test/debug/raw-sse-pretty.test.ts +++ b/packages/coding-agent/test/debug/raw-sse-pretty.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { expandPrettyDataLines } from "../../src/debug/raw-sse"; +import { expandPrettyDataLines } from "@oh-my-pi/pi-coding-agent/debug/raw-sse"; // Wide enough that `truncateToWidth` would clip the payload in the viewer; matches // what real Codex `response.output_item.done` frames look like on the wire. diff --git a/packages/coding-agent/test/debug/raw-sse-report-bundle.test.ts b/packages/coding-agent/test/debug/raw-sse-report-bundle.test.ts index bb43fdb58..3137d9e1a 100644 --- a/packages/coding-agent/test/debug/raw-sse-report-bundle.test.ts +++ b/packages/coding-agent/test/debug/raw-sse-report-bundle.test.ts @@ -3,9 +3,9 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import type { Model } from "@oh-my-pi/pi-ai"; +import { RawSseDebugBuffer } from "@oh-my-pi/pi-coding-agent/debug/raw-sse-buffer"; +import { createReportBundle } from "@oh-my-pi/pi-coding-agent/debug/report-bundle"; import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils"; -import { RawSseDebugBuffer } from "../../src/debug/raw-sse-buffer"; -import { createReportBundle } from "../../src/debug/report-bundle"; const model: Model<"anthropic-messages"> = { id: "claude-test", diff --git a/packages/coding-agent/test/debug/terminal-info.test.ts b/packages/coding-agent/test/debug/terminal-info.test.ts index fdc69144d..b937efbc5 100644 --- a/packages/coding-agent/test/debug/terminal-info.test.ts +++ b/packages/coding-agent/test/debug/terminal-info.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "bun:test"; +import { + collectTerminalState, + formatTerminalState, + type TerminalStateInfo, +} from "@oh-my-pi/pi-coding-agent/debug/terminal-info"; import { TERMINAL } from "@oh-my-pi/pi-tui"; -import { collectTerminalState, formatTerminalState, type TerminalStateInfo } from "../../src/debug/terminal-info"; const sample: TerminalStateInfo = { detectedId: "kitty", diff --git a/packages/coding-agent/test/discovery/agent-discovery-disabled-providers.test.ts b/packages/coding-agent/test/discovery/agent-discovery-disabled-providers.test.ts index cdca0d86a..4efe97225 100644 --- a/packages/coding-agent/test/discovery/agent-discovery-disabled-providers.test.ts +++ b/packages/coding-agent/test/discovery/agent-discovery-disabled-providers.test.ts @@ -6,10 +6,10 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { disableProvider, enableProvider } from "../../src/capability"; -import { clearCache as clearFsCache } from "../../src/capability/fs"; -import { clearClaudePluginRootsCache } from "../../src/discovery/helpers"; -import { discoverAgents } from "../../src/task/discovery"; +import { disableProvider, enableProvider } from "@oh-my-pi/pi-coding-agent/capability"; +import { clearCache as clearFsCache } from "@oh-my-pi/pi-coding-agent/capability/fs"; +import { clearClaudePluginRootsCache } from "@oh-my-pi/pi-coding-agent/discovery/helpers"; +import { discoverAgents } from "@oh-my-pi/pi-coding-agent/task/discovery"; const PLUGIN_AGENT_MD = [ "---", diff --git a/packages/coding-agent/test/discovery/agent-fields.test.ts b/packages/coding-agent/test/discovery/agent-fields.test.ts index 56a3e8974..34371f7b2 100644 --- a/packages/coding-agent/test/discovery/agent-fields.test.ts +++ b/packages/coding-agent/test/discovery/agent-fields.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { Effort } from "@oh-my-pi/pi-ai"; -import { parseAgentFields } from "../../src/discovery/helpers"; +import { parseAgentFields } from "@oh-my-pi/pi-coding-agent/discovery/helpers"; describe("parseAgentFields", () => { test("parses blocking from boolean frontmatter", () => { diff --git a/packages/coding-agent/test/discovery/at-imports.test.ts b/packages/coding-agent/test/discovery/at-imports.test.ts new file mode 100644 index 000000000..7c3279e9f --- /dev/null +++ b/packages/coding-agent/test/discovery/at-imports.test.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { clearCache as clearFsCache } from "@oh-my-pi/pi-coding-agent/capability/fs"; +import { expandAtImports, MAX_AT_IMPORT_DEPTH } from "@oh-my-pi/pi-coding-agent/discovery/at-imports"; + +/** + * Behavior contract for the @-import expander used by every AGENTS.md / + * CLAUDE.md / GEMINI.md loader. Each test names one externally-observable + * promise — relative-resolution, code-block opacity, cycle/depth caps, etc. + */ +describe("expandAtImports", () => { + let tmp: string; + + beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), "omp-at-import-")); + }); + + afterEach(async () => { + clearFsCache(); + await fs.rm(tmp, { recursive: true, force: true }); + }); + + const writeFile = async (relPath: string, content: string): Promise => { + const abs = path.join(tmp, relPath); + await fs.mkdir(path.dirname(abs), { recursive: true }); + await fs.writeFile(abs, content); + return abs; + }; + + test("inlines the entire referenced file when the line is just @path", async () => { + // User's reported case from issue #2111 — CLAUDE.md with body `@AGENTS.md`. + const agents = await writeFile("AGENTS.md", "ALWAYS use uppercase letters."); + const claude = await writeFile("CLAUDE.md", "@AGENTS.md\n"); + + const expanded = await expandAtImports(await fs.readFile(claude, "utf8"), claude); + + expect(expanded.trim()).toBe("ALWAYS use uppercase letters."); + // Sanity: the actual file path resolved was the sibling, not anything else. + expect(await fs.readFile(agents, "utf8")).toBe("ALWAYS use uppercase letters."); + }); + + test("resolves relative paths against the importing file's directory, not cwd", async () => { + // Importing file lives in a subdir; the @-import must look for the + // target alongside the importer, not in process.cwd(). + await writeFile("rules/no-push.md", "NEVER push."); + const agentsPath = await writeFile("rules/AGENTS.md", "Rule: @./no-push.md\n"); + + const expanded = await expandAtImports("Rule: @./no-push.md\n", agentsPath); + + expect(expanded).toContain("Rule: NEVER push."); + expect(expanded).not.toContain("@./no-push.md"); + }); + + test("resolves ~/path against the home override", async () => { + const fakeHome = await fs.mkdtemp(path.join(os.tmpdir(), "omp-at-home-")); + try { + await fs.writeFile(path.join(fakeHome, "prefs.md"), "use 2 spaces"); + const expanded = await expandAtImports("See @~/prefs.md.\n", path.join(tmp, "AGENTS.md"), { + home: fakeHome, + }); + expect(expanded).toContain("See use 2 spaces"); + } finally { + await fs.rm(fakeHome, { recursive: true, force: true }); + } + }); + + test("nests imports recursively", async () => { + await writeFile("c.md", "LEAF."); + await writeFile("b.md", "B then @c.md\n"); + const a = await writeFile("a.md", "A then @b.md\n"); + + const expanded = await expandAtImports(await fs.readFile(a, "utf8"), a); + + expect(expanded).toContain("A then B then LEAF."); + }); + + test("caps recursion at MAX_AT_IMPORT_DEPTH hops", async () => { + // Build a chain longer than the depth cap. At depth=MAX, expand() + // short-circuits before resolving the file's own @-imports, so the + // content at that depth is included verbatim and the import token + // inside it (`@step${MAX+1}.md`) survives unexpanded. + const total = MAX_AT_IMPORT_DEPTH + 2; + for (let i = 0; i < total; i++) { + const body = i === total - 1 ? "TERMINAL" : `step-${i} -> @step${i + 1}.md`; + await writeFile(`step${i}.md`, `${body}\n`); + } + const entry = path.join(tmp, "step0.md"); + const expanded = await expandAtImports(await fs.readFile(entry, "utf8"), entry); + expect(expanded).toContain(`@step${MAX_AT_IMPORT_DEPTH + 1}.md`); + expect(expanded).not.toContain("TERMINAL"); + }); + + test("breaks cycles silently", async () => { + // `@` must follow whitespace or start-of-line to count as an import, + // so the loop bodies use a space before each reference. + const a = await writeFile("loop-a.md", "A: @loop-b.md\n"); + await writeFile("loop-b.md", "B: @loop-a.md\n"); + + const expanded = await expandAtImports(await fs.readFile(a, "utf8"), a); + + // The second hop sees the original importer in `visited` and bails, + // leaving its literal @-token but emitting all earlier text. + expect(expanded).toContain("A: B:"); + expect(expanded).toContain("@loop-a.md"); + }); + + test("leaves the original token untouched when the file is missing", async () => { + const source = path.join(tmp, "AGENTS.md"); + const expanded = await expandAtImports("See @./does-not-exist.md\n", source); + expect(expanded).toContain("@./does-not-exist.md"); + }); + + test("does not expand inside fenced code blocks", async () => { + await writeFile("guide.md", "INLINED"); + const source = path.join(tmp, "AGENTS.md"); + const input = ["Run this:", "```bash", "echo @./guide.md", "```", "Also see @./guide.md."].join("\n"); + + const expanded = await expandAtImports(input, source); + + // Inside the fence the @-token is preserved verbatim. + expect(expanded).toContain("echo @./guide.md"); + // Outside the fence it expands. + expect(expanded).toContain("Also see INLINED"); + }); + + test("does not expand inside inline code spans", async () => { + await writeFile("guide.md", "INLINED"); + const source = path.join(tmp, "AGENTS.md"); + const input = "Install via `npm i @./guide.md` and also @./guide.md.\n"; + + const expanded = await expandAtImports(input, source); + + expect(expanded).toContain("`npm i @./guide.md`"); + expect(expanded).toContain("also INLINED"); + }); + + test("ignores @ embedded mid-token like emails and SSH URLs", async () => { + // Guard against false positives that would otherwise spam debug logs + // or worse, leak filesystem reads triggered by user-supplied text. + await writeFile("guide.md", "INLINED"); + const source = path.join(tmp, "AGENTS.md"); + const input = "Ping me at me@example.com or use git@github.com:foo/bar.git for clones.\n"; + + const expanded = await expandAtImports(input, source); + + expect(expanded).toBe(input); + }); + + test("strips trailing sentence punctuation from the path", async () => { + // The trailing comma/period is sentence grammar, not part of the filename. + await writeFile("guide.md", "INLINED"); + const source = path.join(tmp, "AGENTS.md"); + const input = "See @./guide.md, and then continue.\n"; + + const expanded = await expandAtImports(input, source); + + expect(expanded).toContain("See INLINED, and then continue."); + }); +}); diff --git a/packages/coding-agent/test/discovery/builtin-defaults.test.ts b/packages/coding-agent/test/discovery/builtin-defaults.test.ts index 9a36ca360..ee978574e 100644 --- a/packages/coding-agent/test/discovery/builtin-defaults.test.ts +++ b/packages/coding-agent/test/discovery/builtin-defaults.test.ts @@ -10,24 +10,7 @@ import { BUILTIN_DEFAULTS_PROVIDER_ID, type Rule, ruleCapability } from "@oh-my- import type { LoadContext } from "@oh-my-pi/pi-coding-agent/capability/types"; // Register all discovery providers as a side effect. import "@oh-my-pi/pi-coding-agent/discovery"; - -const EXPECTED_RULE_NAMES = [ - "rs-box-leak", - "rs-future-prelude", - "rs-lazylock", - "rs-match-ergonomics", - "rs-parking-lot", - "rs-result-type", - "ts-bare-catch", - "ts-import-type", - "ts-no-any", - "ts-no-deprecated-leftovers", - "ts-no-dynamic-import", - "ts-no-return-type", - "ts-no-tiny-functions", - "ts-promise-with-resolvers", - "ts-set-map", -].sort(); +import { TtsrManager } from "@oh-my-pi/pi-coding-agent/export/ttsr"; function ruleProvider() { const cap = getCapability(ruleCapability.id); @@ -45,21 +28,31 @@ async function loadBuiltinRules(): Promise { } describe("builtin-defaults rule provider", () => { - it("loads exactly the bundled default rule set, all attributed to the provider", async () => { + it("loads the bundled default rule set, all attributed to the provider", async () => { const rules = await loadBuiltinRules(); - const names = rules.map(r => r.name).sort(); - expect(names).toEqual(EXPECTED_RULE_NAMES); + expect(rules.length).toBeGreaterThan(0); expect(rules.every(r => r._source.provider === BUILTIN_DEFAULTS_PROVIDER_ID)).toBe(true); + const names = rules.map(r => r.name); + // Name-based dedup is first-wins, so a duplicate would be silently shadowed. + expect(new Set(names).size).toBe(names.length); }); - it("parses every bundled rule as a TTSR rule (non-empty condition and scope)", async () => { + it("parses every bundled rule as a TTSR rule (non-empty condition/astCondition and scope)", async () => { const rules = await loadBuiltinRules(); for (const rule of rules) { - expect(rule.condition?.length, `${rule.name} condition`).toBeGreaterThan(0); + const conditionCount = (rule.condition?.length ?? 0) + (rule.astCondition?.length ?? 0); + expect(conditionCount, `${rule.name} condition/astCondition`).toBeGreaterThan(0); expect(rule.scope?.length, `${rule.name} scope`).toBeGreaterThan(0); } }); + it("bundles ast-grep conditions for the redundant-clear-guard rule", async () => { + const rules = await loadBuiltinRules(); + const rule = rules.find(r => r.name === "ts-redundant-clear-guard"); + expect(rule?.condition).toBeUndefined(); + expect(rule?.astCondition?.length).toBeGreaterThan(0); + }); + it("parses YAML list-form conditions from the embedded text", async () => { const rules = await loadBuiltinRules(); const lazylock = rules.find(r => r.name === "rs-lazylock"); @@ -72,6 +65,38 @@ describe("builtin-defaults rule provider", () => { expect(rules.find(r => r.name === "ts-set-map")?.interruptMode).toBe("never"); }); + it("fires the no-test-timers rule on real timers in *.test.ts but not plain *.ts", async () => { + const rules = await loadBuiltinRules(); + const rule = rules.find(r => r.name === "ts-no-test-timers"); + if (!rule) throw new Error("ts-no-test-timers rule missing"); + + const manager = new TtsrManager(); + expect(manager.addRule(rule)).toBe(true); + + for (const snippet of ["await Bun.sleep(10)", "setTimeout(fn, 0)", "setInterval(fn, 5)"]) { + manager.resetBuffer(); + const matches = manager.checkDelta(snippet, { + source: "tool", + toolName: "write", + filePaths: ["packages/x/test/foo.test.ts"], + }); + expect( + matches.map(r => r.name), + snippet, + ).toEqual(["ts-no-test-timers"]); + } + + // Same content in a non-test file is out of scope. + manager.resetBuffer(); + expect( + manager.checkDelta("await Bun.sleep(10)", { + source: "tool", + toolName: "write", + filePaths: ["packages/x/src/foo.ts"], + }), + ).toEqual([]); + }); + it("is the lowest-priority rule provider so user/project rules override defaults", () => { const { cap, provider } = ruleProvider(); const others = cap.providers.filter(p => p.id !== BUILTIN_DEFAULTS_PROVIDER_ID); diff --git a/packages/coding-agent/test/edit/file-snapshot-store.test.ts b/packages/coding-agent/test/edit/file-snapshot-store.test.ts index 978f4a23e..7d0def8ee 100644 --- a/packages/coding-agent/test/edit/file-snapshot-store.test.ts +++ b/packages/coding-agent/test/edit/file-snapshot-store.test.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import type { InMemorySnapshotStore } from "@oh-my-pi/hashline"; -import { canonicalSnapshotKey, getFileSnapshotStore } from "../../src/edit/file-snapshot-store"; +import { canonicalSnapshotKey, getFileSnapshotStore } from "@oh-my-pi/pi-coding-agent/edit/file-snapshot-store"; interface SessionOwner { fileSnapshotStore?: InMemorySnapshotStore; diff --git a/packages/coding-agent/test/emoji-autocomplete.test.ts b/packages/coding-agent/test/emoji-autocomplete.test.ts index 0d02b61f9..e0d71ea20 100644 --- a/packages/coding-agent/test/emoji-autocomplete.test.ts +++ b/packages/coding-agent/test/emoji-autocomplete.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { applyEmojiCompletion, getEmojiSuggestions, tryEmojiInlineReplace } from "../src/modes/emoji-autocomplete"; +import { + applyEmojiCompletion, + getEmojiSuggestions, + tryEmojiInlineReplace, +} from "@oh-my-pi/pi-coding-agent/modes/emoji-autocomplete"; describe("emoji autocomplete", () => { describe("getEmojiSuggestions", () => { diff --git a/packages/coding-agent/test/eval/agent-bridge.test.ts b/packages/coding-agent/test/eval/agent-bridge.test.ts index 87b401d1a..832dd7fba 100644 --- a/packages/coding-agent/test/eval/agent-bridge.test.ts +++ b/packages/coding-agent/test/eval/agent-bridge.test.ts @@ -1,12 +1,12 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { Settings } from "../../src/config/settings"; -import { runEvalAgent } from "../../src/eval/agent-bridge"; -import type { LocalProtocolOptions } from "../../src/internal-urls"; -import type { MCPManager } from "../../src/mcp"; -import * as taskDiscovery from "../../src/task/discovery"; -import * as taskExecutor from "../../src/task/executor"; -import type { AgentDefinition, SingleResult } from "../../src/task/types"; -import type { ToolSession } from "../../src/tools"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { runEvalAgent } from "@oh-my-pi/pi-coding-agent/eval/agent-bridge"; +import type { LocalProtocolOptions } from "@oh-my-pi/pi-coding-agent/internal-urls"; +import type { MCPManager } from "@oh-my-pi/pi-coding-agent/mcp"; +import * as taskDiscovery from "@oh-my-pi/pi-coding-agent/task/discovery"; +import * as taskExecutor from "@oh-my-pi/pi-coding-agent/task/executor"; +import type { AgentDefinition, SingleResult } from "@oh-my-pi/pi-coding-agent/task/types"; +import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; function createResult(): SingleResult { return { diff --git a/packages/coding-agent/test/export/html-template-developer.test.ts b/packages/coding-agent/test/export/html-template-developer.test.ts deleted file mode 100644 index b1f1916aa..000000000 --- a/packages/coding-agent/test/export/html-template-developer.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { TEMPLATE } from "../../src/export/html/template.generated"; - -describe("HTML export template developer message support", () => { - it("renders developer-role messages in the main feed", () => { - expect(TEMPLATE).toContain("msg.role === 'developer'"); - expect(TEMPLATE).toContain("developer-message"); - }); - - it("labels developer entries in the sidebar tree", () => { - expect(TEMPLATE).toContain("tree-role-developer"); - expect(TEMPLATE).toContain("developer:"); - }); - - it("counts developer messages in header stats", () => { - expect(TEMPLATE).toContain("developerMessages"); - }); -}); diff --git a/packages/coding-agent/test/export/html-template-script-substitution.test.ts b/packages/coding-agent/test/export/html-template-script-substitution.test.ts deleted file mode 100644 index f4446ff73..000000000 --- a/packages/coding-agent/test/export/html-template-script-substitution.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { TEMPLATE } from "../../src/export/html/template.generated"; - -// Regression: `String.prototype.replace(string, string)` treats `$'`, `$&`, -// `$$`, `$n`, etc. as substitution patterns. The inlined `