Merge remote-tracking branch 'can1357/main' into feat/advisor-per-agent-toggle
This commit is contained in:
@@ -67,6 +67,7 @@ inprealpha
|
||||
insodimension
|
||||
itertea
|
||||
itzrnvr
|
||||
jaaneek
|
||||
jagravnaik
|
||||
jasonw22
|
||||
jchristman
|
||||
|
||||
@@ -622,10 +622,11 @@ jobs:
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
# Keep npm aligned with trusted publishing setup (>= 11.16.0).
|
||||
# npm runs under Bun when invoked by the release script; npm 12
|
||||
# requires a newer emulated Node version than Bun 1.3 provides.
|
||||
- name: Ensure npm supports trusted publishing
|
||||
if: ${{ !inputs.skip_npm }}
|
||||
run: npm install -g npm@latest
|
||||
run: npm install -g npm@11.17.0
|
||||
- name: Cache bun dependencies
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
@@ -775,9 +776,10 @@ jobs:
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
# Keep npm aligned with trusted publishing setup (>= 11.16.0).
|
||||
# npm runs under Bun when invoked by the release script; npm 12
|
||||
# requires a newer emulated Node version than Bun 1.3 provides.
|
||||
- name: Ensure npm supports trusted publishing
|
||||
run: npm install -g npm@latest
|
||||
run: npm install -g npm@11.17.0
|
||||
- name: Cache bun dependencies
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
|
||||
Generated
+24
-23
@@ -1850,9 +1850,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "ignore"
|
||||
version = "0.4.27"
|
||||
version = "0.4.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe112b004901c62c2faa11f4f75e9864e0cc5af8da71c9115d184a3aa888749f"
|
||||
checksum = "2adf14691c72bcfc1058740436a35bdd3ae9c07d1a941ef00b749e9ea16aefa7"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"globset",
|
||||
@@ -1951,9 +1951,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
version = "0.11.3"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd854a95a4ac672fed8c054136039fd32c22cf039ff09ead7280afe920486483"
|
||||
checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"inotify-sys",
|
||||
@@ -2026,9 +2026,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.31"
|
||||
version = "0.2.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634"
|
||||
checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
"jiff-static",
|
||||
@@ -2053,9 +2053,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.31"
|
||||
version = "0.2.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2"
|
||||
checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -2064,9 +2064,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jiff-tzdb"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6142247df1a93c2b3587402a19710be3e6e942f1581a1702e76408f2c21d6590"
|
||||
checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
|
||||
|
||||
[[package]]
|
||||
name = "jiff-tzdb-platform"
|
||||
@@ -2881,7 +2881,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-ast"
|
||||
version = "16.3.12"
|
||||
version = "16.3.14"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ast-grep-core",
|
||||
@@ -2950,7 +2950,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-iso"
|
||||
version = "16.3.12"
|
||||
version = "16.3.14"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"libc",
|
||||
@@ -2962,13 +2962,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-natives"
|
||||
version = "16.3.12"
|
||||
version = "16.3.14"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
"ast-grep-core",
|
||||
"base64",
|
||||
"clap",
|
||||
"clipboard-win",
|
||||
"flume",
|
||||
"fontdue",
|
||||
"globset",
|
||||
@@ -3014,7 +3015,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-shell"
|
||||
version = "16.3.12"
|
||||
version = "16.3.14"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"brush-builtins",
|
||||
@@ -3063,7 +3064,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-walker"
|
||||
version = "16.3.12"
|
||||
version = "16.3.14"
|
||||
dependencies = [
|
||||
"dashmap",
|
||||
"globset",
|
||||
@@ -3403,9 +3404,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.4"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
|
||||
checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
@@ -3415,9 +3416,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
@@ -5765,18 +5766,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.53"
|
||||
version = "0.8.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1"
|
||||
checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.53"
|
||||
version = "0.8.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71"
|
||||
checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ exclude = ["crates/vendor/brush-core", "crates/vendor/brush-builtins"]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
version = "16.3.12"
|
||||
version = "16.3.14"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Can Boluk"]
|
||||
@@ -269,6 +269,7 @@ napi-derive = "3"
|
||||
# Terminal & PTY
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
arboard = { version = "3.6.1", features = ["wayland-data-control"] }
|
||||
clipboard-win = "5.4"
|
||||
icy_sixel = "0.5"
|
||||
portable-pty = "0.9"
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
},
|
||||
"packages/agent": {
|
||||
"name": "@oh-my-pi/pi-agent-core",
|
||||
"version": "16.3.12",
|
||||
"version": "16.3.14",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-ai": "catalog:",
|
||||
"@oh-my-pi/pi-catalog": "catalog:",
|
||||
@@ -39,7 +39,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@oh-my-pi/pi-ai",
|
||||
"version": "16.3.12",
|
||||
"version": "16.3.14",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "catalog:",
|
||||
"@oh-my-pi/pi-catalog": "catalog:",
|
||||
@@ -55,7 +55,7 @@
|
||||
},
|
||||
"packages/catalog": {
|
||||
"name": "@oh-my-pi/pi-catalog",
|
||||
"version": "16.3.12",
|
||||
"version": "16.3.14",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
@@ -69,7 +69,7 @@
|
||||
},
|
||||
"packages/coding-agent": {
|
||||
"name": "@oh-my-pi/pi-coding-agent",
|
||||
"version": "16.3.12",
|
||||
"version": "16.3.14",
|
||||
"bin": {
|
||||
"omp": "src/cli.ts",
|
||||
},
|
||||
@@ -137,7 +137,7 @@
|
||||
},
|
||||
"packages/hashline": {
|
||||
"name": "@oh-my-pi/hashline",
|
||||
"version": "16.3.12",
|
||||
"version": "16.3.14",
|
||||
"dependencies": {
|
||||
"diff": "catalog:",
|
||||
"lru-cache": "catalog:",
|
||||
@@ -148,7 +148,7 @@
|
||||
},
|
||||
"packages/mnemopi": {
|
||||
"name": "@oh-my-pi/pi-mnemopi",
|
||||
"version": "16.3.12",
|
||||
"version": "16.3.14",
|
||||
"bin": {
|
||||
"mnemopi": "src/cli.ts",
|
||||
},
|
||||
@@ -174,7 +174,7 @@
|
||||
},
|
||||
"packages/natives": {
|
||||
"name": "@oh-my-pi/pi-natives",
|
||||
"version": "16.3.12",
|
||||
"version": "16.3.14",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
@@ -182,7 +182,7 @@
|
||||
},
|
||||
"packages/snapcompact": {
|
||||
"name": "@oh-my-pi/snapcompact",
|
||||
"version": "16.3.12",
|
||||
"version": "16.3.14",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-ai": "catalog:",
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
@@ -195,7 +195,7 @@
|
||||
},
|
||||
"packages/stats": {
|
||||
"name": "@oh-my-pi/omp-stats",
|
||||
"version": "16.3.12",
|
||||
"version": "16.3.14",
|
||||
"bin": {
|
||||
"omp-stats": "./src/index.ts",
|
||||
},
|
||||
@@ -221,7 +221,7 @@
|
||||
},
|
||||
"packages/swarm-extension": {
|
||||
"name": "@oh-my-pi/swarm-extension",
|
||||
"version": "16.3.12",
|
||||
"version": "16.3.14",
|
||||
"bin": {
|
||||
"omp-swarm": "src/cli.ts",
|
||||
},
|
||||
@@ -247,7 +247,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@oh-my-pi/pi-tui",
|
||||
"version": "16.3.12",
|
||||
"version": "16.3.14",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
@@ -288,7 +288,7 @@
|
||||
},
|
||||
"packages/utils": {
|
||||
"name": "@oh-my-pi/pi-utils",
|
||||
"version": "16.3.12",
|
||||
"version": "16.3.14",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
"handlebars": "catalog:",
|
||||
@@ -301,7 +301,7 @@
|
||||
},
|
||||
"packages/wire": {
|
||||
"name": "@oh-my-pi/pi-wire",
|
||||
"version": "16.3.12",
|
||||
"version": "16.3.14",
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
},
|
||||
@@ -338,18 +338,18 @@
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@napi-rs/cli": "3.7.0",
|
||||
"@oh-my-pi/hashline": "16.3.12",
|
||||
"@oh-my-pi/omp-stats": "16.3.12",
|
||||
"@oh-my-pi/pi-agent-core": "16.3.12",
|
||||
"@oh-my-pi/pi-ai": "16.3.12",
|
||||
"@oh-my-pi/pi-catalog": "16.3.12",
|
||||
"@oh-my-pi/pi-coding-agent": "16.3.12",
|
||||
"@oh-my-pi/pi-mnemopi": "16.3.12",
|
||||
"@oh-my-pi/pi-natives": "16.3.12",
|
||||
"@oh-my-pi/pi-tui": "16.3.12",
|
||||
"@oh-my-pi/pi-utils": "16.3.12",
|
||||
"@oh-my-pi/pi-wire": "16.3.12",
|
||||
"@oh-my-pi/snapcompact": "16.3.12",
|
||||
"@oh-my-pi/hashline": "16.3.14",
|
||||
"@oh-my-pi/omp-stats": "16.3.14",
|
||||
"@oh-my-pi/pi-agent-core": "16.3.14",
|
||||
"@oh-my-pi/pi-ai": "16.3.14",
|
||||
"@oh-my-pi/pi-catalog": "16.3.14",
|
||||
"@oh-my-pi/pi-coding-agent": "16.3.14",
|
||||
"@oh-my-pi/pi-mnemopi": "16.3.14",
|
||||
"@oh-my-pi/pi-natives": "16.3.14",
|
||||
"@oh-my-pi/pi-tui": "16.3.14",
|
||||
"@oh-my-pi/pi-utils": "16.3.14",
|
||||
"@oh-my-pi/pi-wire": "16.3.14",
|
||||
"@oh-my-pi/snapcompact": "16.3.14",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/context-async-hooks": "^2.7.1",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
|
||||
@@ -811,7 +811,7 @@
|
||||
|
||||
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
|
||||
|
||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="],
|
||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="],
|
||||
|
||||
"@puppeteer/browsers": ["@puppeteer/browsers@3.0.6", "", { "dependencies": { "modern-tar": "^0.7.6", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA=="],
|
||||
|
||||
@@ -963,7 +963,7 @@
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.4", "", { "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", "electron-to-chromium": "^1.5.376", "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw=="],
|
||||
"browserslist": ["browserslist@4.28.5", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001800", "electron-to-chromium": "^1.5.387", "node-releases": "^2.0.50", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ grep-searcher.workspace = true
|
||||
html-to-markdown-rs.workspace = true
|
||||
icy_sixel.workspace = true
|
||||
ignore.workspace = true
|
||||
image.workspace = true
|
||||
image = { workspace = true, features = ["bmp"] }
|
||||
inferno.workspace = true
|
||||
memmap2.workspace = true
|
||||
napi.workspace = true
|
||||
@@ -61,6 +61,7 @@ libc.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { workspace = true, features = ["Wdk_Storage_FileSystem", "Win32_Security"] }
|
||||
clipboard-win.workspace = true
|
||||
winreg.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
@@ -30,7 +30,14 @@ fn encode_png(image: ImageData<'_>) -> Result<Vec<u8>> {
|
||||
let bytes = image.bytes.into_owned();
|
||||
let buffer = RgbaImage::from_raw(width, height, bytes)
|
||||
.ok_or_else(|| Error::from_reason("Clipboard image buffer size mismatch"))?;
|
||||
let capacity = width.saturating_mul(height).saturating_mul(4) as usize;
|
||||
rgba_to_png(buffer)
|
||||
}
|
||||
|
||||
fn rgba_to_png(buffer: RgbaImage) -> Result<Vec<u8>> {
|
||||
let capacity = (buffer
|
||||
.width()
|
||||
.saturating_mul(buffer.height())
|
||||
.saturating_mul(4)) as usize;
|
||||
let mut output = Vec::with_capacity(capacity);
|
||||
DynamicImage::ImageRgba8(buffer)
|
||||
.write_to(&mut Cursor::new(&mut output), ImageFormat::Png)
|
||||
@@ -38,6 +45,88 @@ fn encode_png(image: ImageData<'_>) -> Result<Vec<u8>> {
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Decode a packed DIB clipboard payload (`CF_DIB`: a `BITMAPINFOHEADER`-family
|
||||
/// header, optional bitfield masks and palette, then the pixel array) into PNG
|
||||
/// bytes.
|
||||
///
|
||||
/// The payload is wrapped in a synthesized `BITMAPFILEHEADER` and decoded
|
||||
/// through the BMP *file* path so the explicit `bfOffBits` pins the pixel
|
||||
/// offset. This matters: the header-less decode path arboard uses mis-places
|
||||
/// the pixel offset for V4/V5 headers with `BI_BITFIELDS` compression (it
|
||||
/// skips 12 trailing mask bytes that those headers embed instead), which is
|
||||
/// why Qt-based screenshot tools (`PixPin`, `Snipaste`, ...) fail through
|
||||
/// arboard in the first place (#3426).
|
||||
#[cfg_attr(
|
||||
not(windows),
|
||||
allow(
|
||||
dead_code,
|
||||
reason = "reached only by the Windows clipboard fallback; kept target-independent so unit \
|
||||
tests cover it on every host"
|
||||
)
|
||||
)]
|
||||
fn dib_to_png(dib: &[u8]) -> Result<Vec<u8>> {
|
||||
const FILE_HEADER_SIZE: u64 = 14;
|
||||
const INFO_HEADER_SIZE: u64 = 40;
|
||||
const BI_BITFIELDS: u32 = 3;
|
||||
|
||||
if dib.len() < INFO_HEADER_SIZE as usize {
|
||||
return Err(Error::from_reason("Clipboard DIB shorter than BITMAPINFOHEADER"));
|
||||
}
|
||||
let u32_at =
|
||||
|at: usize| u32::from_le_bytes(dib[at..at + 4].try_into().expect("bounds checked above"));
|
||||
let header_size = u64::from(u32_at(0));
|
||||
if header_size < INFO_HEADER_SIZE || header_size > dib.len() as u64 {
|
||||
return Err(Error::from_reason("Clipboard DIB header size out of range"));
|
||||
}
|
||||
let bit_count = u16::from_le_bytes([dib[14], dib[15]]);
|
||||
let compression = u32_at(16);
|
||||
let colors_used = u64::from(u32_at(32));
|
||||
|
||||
// A plain BITMAPINFOHEADER with BI_BITFIELDS is trailed by three DWORD
|
||||
// masks; larger (V2..V5) headers embed the masks in the header itself.
|
||||
let mask_bytes: u64 = if header_size == INFO_HEADER_SIZE && compression == BI_BITFIELDS {
|
||||
12
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let palette_entries: u64 = if colors_used != 0 {
|
||||
colors_used
|
||||
} else if bit_count <= 8 {
|
||||
1u64 << bit_count
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let pixel_offset =
|
||||
u32::try_from(FILE_HEADER_SIZE + header_size + mask_bytes + palette_entries * 4)
|
||||
.map_err(|_| Error::from_reason("Clipboard DIB layout overflow"))?;
|
||||
let file_size = u32::try_from(FILE_HEADER_SIZE + dib.len() as u64)
|
||||
.map_err(|_| Error::from_reason("Clipboard DIB too large"))?;
|
||||
|
||||
let mut bmp = Vec::with_capacity(FILE_HEADER_SIZE as usize + dib.len());
|
||||
bmp.extend_from_slice(b"BM");
|
||||
bmp.extend_from_slice(&file_size.to_le_bytes());
|
||||
bmp.extend_from_slice(&0u32.to_le_bytes());
|
||||
bmp.extend_from_slice(&pixel_offset.to_le_bytes());
|
||||
bmp.extend_from_slice(dib);
|
||||
|
||||
let decoded = image::load_from_memory_with_format(&bmp, ImageFormat::Bmp)
|
||||
.map_err(|err| Error::from_reason(format!("Failed to decode clipboard DIB: {err}")))?;
|
||||
rgba_to_png(decoded.into_rgba8())
|
||||
}
|
||||
|
||||
/// Read the raw `CF_DIB` bytes from the Windows clipboard.
|
||||
///
|
||||
/// Windows synthesizes `CF_DIB` from whatever bitmap formats are present, so
|
||||
/// it is available whenever the clipboard holds any image at all.
|
||||
#[cfg(windows)]
|
||||
fn read_raw_cf_dib() -> Option<Vec<u8>> {
|
||||
let clip = clipboard_win::Clipboard::new_attempts(10).ok()?;
|
||||
let mut dib = Vec::new();
|
||||
clipboard_win::raw::get_vec(clipboard_win::formats::CF_DIB, &mut dib).ok()?;
|
||||
drop(clip);
|
||||
(!dib.is_empty()).then_some(dib)
|
||||
}
|
||||
|
||||
/// Copy plain text to the system clipboard.
|
||||
///
|
||||
/// # Parameters
|
||||
@@ -120,7 +209,160 @@ pub fn read_image_from_clipboard() -> task::Promise<Option<ClipboardImage>> {
|
||||
}))
|
||||
},
|
||||
Err(ClipboardError::ContentNotAvailable) => Ok(None),
|
||||
Err(err) => Err(Error::from_reason(format!("Failed to read clipboard image: {err}"))),
|
||||
Err(err) => {
|
||||
// arboard rejects the CF_DIBV5 payloads Qt-based screenshot
|
||||
// tools (PixPin, Snipaste, ...) produce; decode the raw CF_DIB
|
||||
// ourselves before surfacing the error (#3426). A fallback
|
||||
// decode failure keeps the original arboard error.
|
||||
#[cfg(windows)]
|
||||
if let Some(bytes) = read_raw_cf_dib().and_then(|dib| dib_to_png(&dib).ok()) {
|
||||
return Ok(Some(ClipboardImage {
|
||||
data: Uint8Array::from(bytes),
|
||||
mime_type: "image/png".to_string(),
|
||||
}));
|
||||
}
|
||||
Err(Error::from_reason(format!("Failed to read clipboard image: {err}")))
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::dib_to_png;
|
||||
|
||||
fn push32(v: u32, out: &mut Vec<u8>) {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
|
||||
fn push16(v: u16, out: &mut Vec<u8>) {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
|
||||
/// 2x2 bottom-up BGRA pixel array: memory rows are [red, green] (bottom)
|
||||
/// then [blue, white] (top), all with alpha 0xff.
|
||||
const PIXELS_2X2: [u8; 16] = [
|
||||
0x00, 0x00, 0xff, 0xff, // (0,1) red
|
||||
0x00, 0xff, 0x00, 0xff, // (1,1) green
|
||||
0xff, 0x00, 0x00, 0xff, // (0,0) blue
|
||||
0xff, 0xff, 0xff, 0xff, // (1,0) white
|
||||
];
|
||||
|
||||
/// `CF_DIB` as Qt's clipboard writer emits it for 32-bit content: a plain
|
||||
/// `BITMAPINFOHEADER` with `BI_BITFIELDS` compression and three DWORD
|
||||
/// masks between header and pixels.
|
||||
fn qt_cf_dib(width: u32, height: u32, pixels_bgra: &[u8], compression: u32) -> Vec<u8> {
|
||||
let mut d = Vec::with_capacity(52 + pixels_bgra.len());
|
||||
push32(40, &mut d); // biSize
|
||||
push32(width, &mut d);
|
||||
push32(height, &mut d); // positive: bottom-up
|
||||
push16(1, &mut d); // biPlanes
|
||||
push16(32, &mut d); // biBitCount
|
||||
push32(compression, &mut d);
|
||||
push32(pixels_bgra.len() as u32, &mut d); // biSizeImage
|
||||
push32(0, &mut d); // biXPelsPerMeter
|
||||
push32(0, &mut d); // biYPelsPerMeter
|
||||
push32(0, &mut d); // biClrUsed
|
||||
push32(0, &mut d); // biClrImportant
|
||||
if compression == 3 {
|
||||
push32(0x00ff_0000, &mut d); // red mask
|
||||
push32(0x0000_ff00, &mut d); // green mask
|
||||
push32(0x0000_00ff, &mut d); // blue mask
|
||||
}
|
||||
d.extend_from_slice(pixels_bgra);
|
||||
d
|
||||
}
|
||||
|
||||
/// `CF_DIBV5` as PixPin (Qt) places it, after arboard's
|
||||
/// `maybe_tweak_header` rewrite: a 124-byte `BITMAPV5HEADER` carrying
|
||||
/// `BI_BITFIELDS` compression with the BGRA masks embedded in the header
|
||||
/// and pixels immediately after it. This is the exact buffer shape that
|
||||
/// arboard's header-less BMP decode rejects with `ConversionFailure`
|
||||
/// (issue #3426); the file-header wrap must decode it.
|
||||
fn pixpin_dibv5_tweaked(width: u32, height: u32, pixels_bgra: &[u8]) -> Vec<u8> {
|
||||
let mut d = Vec::with_capacity(124 + pixels_bgra.len());
|
||||
push32(124, &mut d); // bV5Size
|
||||
push32(width, &mut d);
|
||||
push32(height, &mut d);
|
||||
push16(1, &mut d); // bV5Planes
|
||||
push16(32, &mut d); // bV5BitCount
|
||||
push32(3, &mut d); // bV5Compression = BI_BITFIELDS (arboard-tweaked)
|
||||
push32(0, &mut d); // bV5SizeImage
|
||||
push32(0, &mut d); // bV5XPelsPerMeter
|
||||
push32(0, &mut d); // bV5YPelsPerMeter
|
||||
push32(0, &mut d); // bV5ClrUsed
|
||||
push32(0, &mut d); // bV5ClrImportant
|
||||
push32(0x00ff_0000, &mut d); // bV5RedMask
|
||||
push32(0x0000_ff00, &mut d); // bV5GreenMask
|
||||
push32(0x0000_00ff, &mut d); // bV5BlueMask
|
||||
push32(0xff00_0000, &mut d); // bV5AlphaMask
|
||||
push32(0x7352_4742, &mut d); // bV5CSType = LCS_sRGB
|
||||
d.extend_from_slice(&[0u8; 36]); // bV5Endpoints
|
||||
push32(0, &mut d); // bV5GammaRed
|
||||
push32(0, &mut d); // bV5GammaGreen
|
||||
push32(0, &mut d); // bV5GammaBlue
|
||||
push32(4, &mut d); // bV5Intent = LCS_GM_IMAGES
|
||||
push32(0, &mut d); // bV5ProfileData
|
||||
push32(0, &mut d); // bV5ProfileSize
|
||||
push32(0, &mut d); // bV5Reserved
|
||||
assert_eq!(d.len(), 124);
|
||||
d.extend_from_slice(pixels_bgra);
|
||||
d
|
||||
}
|
||||
|
||||
fn decode_pixels(png: &[u8]) -> (u32, u32, Vec<[u8; 4]>) {
|
||||
let img = image::load_from_memory(png).expect("fallback output must be valid PNG");
|
||||
let rgba = img.into_rgba8();
|
||||
let (w, h) = rgba.dimensions();
|
||||
let px = rgba.pixels().map(|p| p.0).collect();
|
||||
(w, h, px)
|
||||
}
|
||||
|
||||
const RED: [u8; 4] = [255, 0, 0, 255];
|
||||
const GREEN: [u8; 4] = [0, 255, 0, 255];
|
||||
const BLUE: [u8; 4] = [0, 0, 255, 255];
|
||||
const WHITE: [u8; 4] = [255, 255, 255, 255];
|
||||
|
||||
#[test]
|
||||
fn decodes_qt_cf_dib_with_bitfields_masks() {
|
||||
let dib = qt_cf_dib(2, 2, &PIXELS_2X2, 3);
|
||||
let png = dib_to_png(&dib).expect("BI_BITFIELDS CF_DIB must decode");
|
||||
let (w, h, px) = decode_pixels(&png);
|
||||
assert_eq!((w, h), (2, 2));
|
||||
// Row order flipped versus the bottom-up pixel array; BGRA -> RGBA.
|
||||
assert_eq!(px, vec![BLUE, WHITE, RED, GREEN]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_pixpin_dibv5_payload_that_arboard_rejects() {
|
||||
let dib = pixpin_dibv5_tweaked(2, 2, &PIXELS_2X2);
|
||||
let png = dib_to_png(&dib).expect("V5 BI_BITFIELDS DIB must decode");
|
||||
let (w, h, px) = decode_pixels(&png);
|
||||
assert_eq!((w, h), (2, 2));
|
||||
assert_eq!(px, vec![BLUE, WHITE, RED, GREEN]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_plain_bi_rgb_dib() {
|
||||
// The common "copy image" payload: BI_RGB, 32-bit, no masks. The
|
||||
// fourth byte is unused per the DIB contract — zero it to prove the
|
||||
// decode still yields opaque pixels.
|
||||
let mut pixels = PIXELS_2X2;
|
||||
for alpha in pixels.iter_mut().skip(3).step_by(4) {
|
||||
*alpha = 0;
|
||||
}
|
||||
let dib = qt_cf_dib(2, 2, &pixels, 0);
|
||||
let png = dib_to_png(&dib).expect("BI_RGB CF_DIB must decode");
|
||||
let (w, h, px) = decode_pixels(&png);
|
||||
assert_eq!((w, h), (2, 2));
|
||||
assert_eq!(px, vec![BLUE, WHITE, RED, GREEN]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_dib() {
|
||||
assert!(dib_to_png(&[0u8; 12]).is_err(), "short buffer must not decode");
|
||||
let mut oversized_header = qt_cf_dib(2, 2, &PIXELS_2X2, 3);
|
||||
oversized_header[0..4].copy_from_slice(&0xffff_ffffu32.to_le_bytes());
|
||||
assert!(dib_to_png(&oversized_header).is_err(), "header size beyond buffer must not decode");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,7 +248,7 @@ fn create_windows_napi_tokio_runtime() -> Option<tokio::runtime::Runtime> {
|
||||
/// 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 = "__piNativesV16_3_12")]
|
||||
#[napi(js_name = "__piNativesV16_3_14")]
|
||||
pub const fn pi_natives_version_sentinel() {}
|
||||
|
||||
/// Native module entry point: install crash diagnostics before any tool can
|
||||
|
||||
+132
-33
@@ -5,7 +5,7 @@ use std::{collections::HashMap, sync::Arc};
|
||||
use napi::{
|
||||
Env, Result,
|
||||
bindgen_prelude::*,
|
||||
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
|
||||
threadsafe_function::{ThreadsafeFunction, UnknownReturnValue},
|
||||
};
|
||||
use napi_derive::napi;
|
||||
use pi_shell::{
|
||||
@@ -216,7 +216,7 @@ impl Shell {
|
||||
env: &'env Env,
|
||||
options: ShellRunOptions<'env>,
|
||||
#[napi(ts_arg_type = "((error: Error | null, chunk: string) => void) | undefined | null")]
|
||||
on_chunk: Option<ThreadsafeFunction<String>>,
|
||||
on_chunk: Option<ThreadsafeFunction<String, UnknownReturnValue>>,
|
||||
) -> Result<PromiseRaw<'env, ShellRunResult>> {
|
||||
let cancel_token = task::CancelToken::new(options.timeout_ms, options.signal);
|
||||
let inner = Arc::clone(&self.inner);
|
||||
@@ -269,7 +269,7 @@ pub fn execute_shell<'env>(
|
||||
env: &'env Env,
|
||||
options: ShellExecuteOptions<'env>,
|
||||
#[napi(ts_arg_type = "((error: Error | null, chunk: string) => void) | undefined | null")]
|
||||
on_chunk: Option<ThreadsafeFunction<String>>,
|
||||
on_chunk: Option<ThreadsafeFunction<String, UnknownReturnValue>>,
|
||||
) -> Result<PromiseRaw<'env, ShellRunResult>> {
|
||||
let cancel_token = task::CancelToken::new(options.timeout_ms, options.signal);
|
||||
let exec_options = CoreShellExecuteOptions {
|
||||
@@ -294,42 +294,66 @@ pub fn execute_shell<'env>(
|
||||
})
|
||||
}
|
||||
|
||||
/// Capacity (in chunks) of the queue between the pipe readers and the JS
|
||||
/// forwarding pump. One queued chunk is at most one pipe read (≤64 KiB), so
|
||||
/// the Rust side of the bridge holds ~4 MiB worst case before the readers'
|
||||
/// `send_async` parks — which in turn parks the child on its stdout/stderr
|
||||
/// pipe (ordinary pipe backpressure) instead of buffering the surplus in
|
||||
/// process memory (#4078).
|
||||
const BRIDGE_QUEUE_CHUNKS: usize = 64;
|
||||
|
||||
fn bridge_chunks(
|
||||
on_chunk: Option<ThreadsafeFunction<String>>,
|
||||
on_chunk: Option<ThreadsafeFunction<String, UnknownReturnValue>>,
|
||||
) -> (Option<flume::Sender<String>>, Option<napi::tokio::task::JoinHandle<()>>) {
|
||||
let Some(on_chunk) = on_chunk else {
|
||||
return (None, None);
|
||||
};
|
||||
let (tx, rx) = flume::unbounded::<String>();
|
||||
let handle = napi::tokio::spawn(async move {
|
||||
// Hard cap on one coalesced batch so the JS main thread never sees a
|
||||
// multi-MB napi callback (a giant single string would stall sanitize +
|
||||
// tail-buffer maintenance for the whole copy).
|
||||
const MAX_BATCH_BYTES: usize = 64 * 1024;
|
||||
// Initial capacity sized for typical bursty pipe output. Re-allocated
|
||||
// each batch because `String` ownership is moved into the napi call.
|
||||
const INITIAL_BATCH_CAP: usize = 8 * 1024;
|
||||
let mut batch = String::with_capacity(INITIAL_BATCH_CAP);
|
||||
while let Ok(first) = rx.recv_async().await {
|
||||
batch.push_str(&first);
|
||||
// Greedily drain everything already queued. Child processes that
|
||||
// write byte-at-a-time (printf-style progress, llama-cli token
|
||||
// streams) otherwise produce one napi callback per `write(2)`,
|
||||
// saturating the JS main thread (~200% CPU observed) and leaving
|
||||
// the queue draining long after the child exits.
|
||||
while batch.len() < MAX_BATCH_BYTES {
|
||||
match rx.try_recv() {
|
||||
Ok(more) => batch.push_str(&more),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
let payload = std::mem::replace(&mut batch, String::with_capacity(INITIAL_BATCH_CAP));
|
||||
on_chunk.call(Ok(payload), ThreadsafeFunctionCallMode::NonBlocking);
|
||||
}
|
||||
});
|
||||
let (tx, rx) = flume::bounded::<String>(BRIDGE_QUEUE_CHUNKS);
|
||||
let handle = napi::tokio::spawn(pump_chunks(rx, async move |payload: String| {
|
||||
// `call_async` resolves only after the JS callback ran, so at most
|
||||
// one batch sits in the napi queue at a time and the JS event loop's
|
||||
// actual consumption rate backpressures the whole pipeline. An error
|
||||
// means the JS side is gone (env teardown) — stop forwarding.
|
||||
on_chunk.call_async(Ok(payload)).await.is_ok()
|
||||
}));
|
||||
(Some(tx), Some(handle))
|
||||
}
|
||||
|
||||
/// Drain `rx`, greedily coalescing queued chunks into ≤64 KiB batches, and
|
||||
/// feed each batch to `forward`, awaiting its completion before pulling more.
|
||||
/// Returns when `rx` disconnects (all senders dropped) or `forward` reports
|
||||
/// the consumer is gone; dropping `rx` then disconnects the channel so
|
||||
/// parked/future senders fail fast and the pipe readers keep draining the
|
||||
/// child instead of wedging it.
|
||||
async fn pump_chunks(rx: flume::Receiver<String>, mut forward: impl AsyncFnMut(String) -> bool) {
|
||||
// Hard cap on one coalesced batch so the JS main thread never sees a
|
||||
// multi-MB napi callback (a giant single string would stall sanitize +
|
||||
// tail-buffer maintenance for the whole copy).
|
||||
const MAX_BATCH_BYTES: usize = 64 * 1024;
|
||||
// Initial capacity sized for typical bursty pipe output. Re-allocated
|
||||
// each batch because `String` ownership is moved into the napi call.
|
||||
const INITIAL_BATCH_CAP: usize = 8 * 1024;
|
||||
let mut batch = String::with_capacity(INITIAL_BATCH_CAP);
|
||||
while let Ok(first) = rx.recv_async().await {
|
||||
batch.push_str(&first);
|
||||
// Greedily drain everything already queued. Child processes that
|
||||
// write byte-at-a-time (printf-style progress, llama-cli token
|
||||
// streams) otherwise produce one napi callback per `write(2)`,
|
||||
// saturating the JS main thread (~200% CPU observed) and leaving
|
||||
// the queue draining long after the child exits.
|
||||
while batch.len() < MAX_BATCH_BYTES {
|
||||
match rx.try_recv() {
|
||||
Ok(more) => batch.push_str(&more),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
let payload = std::mem::replace(&mut batch, String::with_capacity(INITIAL_BATCH_CAP));
|
||||
if !forward(payload).await {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of [`apply_bash_fixups`]: a possibly-rewritten command plus the
|
||||
/// substrings that were removed (in source order).
|
||||
#[napi(object)]
|
||||
@@ -360,7 +384,6 @@ pub fn apply_bash_fixups(command: String) -> BashFixupResult {
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(unix)]
|
||||
use flume;
|
||||
use pi_shell::{
|
||||
ShellRunOptions as CoreShellRunOptions,
|
||||
@@ -368,7 +391,83 @@ mod tests {
|
||||
};
|
||||
use tokio::time;
|
||||
|
||||
use super::CoreShell;
|
||||
use super::{BRIDGE_QUEUE_CHUNKS, CoreShell, pump_chunks};
|
||||
|
||||
/// Regression for #4078: the reader→JS bridge queue must stay bounded when
|
||||
/// the JS side (here: a deliberately slow `forward`) cannot keep up with a
|
||||
/// fast producer, and backpressure must never drop or reorder chunks. On
|
||||
/// the pre-fix bridge (`flume::unbounded` + fire-and-forget
|
||||
/// `ThreadsafeFunctionCallMode::NonBlocking`) the same harness accumulates
|
||||
/// the producer's entire surplus in the queue (measured: a 32 MiB stream
|
||||
/// queued all 33_554_432 bytes while the consumer stalled).
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bridge_pump_bounds_queue_and_delivers_all_bytes() {
|
||||
const CHUNKS: usize = 512;
|
||||
const CHUNK_BYTES: usize = 4096;
|
||||
let (tx, rx) = flume::bounded::<String>(BRIDGE_QUEUE_CHUNKS);
|
||||
let producer = tokio::spawn(async move {
|
||||
let mut expected = String::with_capacity(CHUNKS * CHUNK_BYTES);
|
||||
let mut max_queued = 0usize;
|
||||
for i in 0..CHUNKS {
|
||||
let chunk = format!("[{i:06}]{}", "x".repeat(CHUNK_BYTES - 8));
|
||||
expected.push_str(&chunk);
|
||||
tx.send_async(chunk)
|
||||
.await
|
||||
.expect("pump should outlive the producer");
|
||||
max_queued = max_queued.max(tx.len());
|
||||
}
|
||||
(expected, max_queued)
|
||||
});
|
||||
|
||||
let mut received = String::with_capacity(CHUNKS * CHUNK_BYTES);
|
||||
time::timeout(
|
||||
Duration::from_secs(30),
|
||||
pump_chunks(rx, async |payload: String| {
|
||||
received.push_str(&payload);
|
||||
// Emulate a busy JS event loop: each napi callback takes a while.
|
||||
time::sleep(Duration::from_micros(500)).await;
|
||||
true
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("pump should finish once the producer hangs up");
|
||||
|
||||
let (expected, max_queued) = producer.await.expect("producer task");
|
||||
assert!(
|
||||
max_queued <= BRIDGE_QUEUE_CHUNKS,
|
||||
"bridge queue grew past its bound: {max_queued} chunks",
|
||||
);
|
||||
assert_eq!(received.len(), expected.len(), "bytes were dropped or duplicated");
|
||||
assert_eq!(received, expected, "chunks must arrive losslessly and in order");
|
||||
}
|
||||
|
||||
/// When the JS side dies (`forward` fails: threadsafe function aborted on
|
||||
/// env teardown), the pump must drop its receiver so parked and future
|
||||
/// sends fail fast — the pipe readers keep draining the child instead of
|
||||
/// wedging it on a full bridge queue.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bridge_pump_death_disconnects_channel_without_blocking_senders() {
|
||||
let (tx, rx) = flume::bounded::<String>(4);
|
||||
let pump = tokio::spawn(pump_chunks(rx, async |_payload: String| false));
|
||||
let producer = tokio::spawn(async move {
|
||||
let mut disconnected = 0usize;
|
||||
for _ in 0..64 {
|
||||
if tx.send_async("x".repeat(1024)).await.is_err() {
|
||||
disconnected += 1;
|
||||
}
|
||||
}
|
||||
disconnected
|
||||
});
|
||||
let disconnected = time::timeout(Duration::from_secs(5), producer)
|
||||
.await
|
||||
.expect("sends must not park once the consumer died")
|
||||
.expect("producer task");
|
||||
assert!(disconnected > 0, "channel should disconnect after the pump stops");
|
||||
time::timeout(Duration::from_secs(5), pump)
|
||||
.await
|
||||
.expect("pump should exit after forward fails")
|
||||
.expect("pump task");
|
||||
}
|
||||
|
||||
mod child_session_action_tests {
|
||||
use pi_shell::{ChildSessionAction, child_session_action};
|
||||
|
||||
@@ -1649,7 +1649,7 @@ async fn read_output(
|
||||
let pending = &buf[..it];
|
||||
match str::from_utf8(pending) {
|
||||
Ok(text) => {
|
||||
emit_chunk(text, on_chunk.as_ref());
|
||||
emit_chunk(text, on_chunk.as_ref()).await;
|
||||
it = 0;
|
||||
break;
|
||||
},
|
||||
@@ -1658,7 +1658,7 @@ async fn read_output(
|
||||
if p > 0 {
|
||||
// SAFETY: [..p] is guaranteed valid UTF-8 by valid_up_to().
|
||||
let text = unsafe { str::from_utf8_unchecked(&pending[..p]) };
|
||||
emit_chunk(text, on_chunk.as_ref());
|
||||
emit_chunk(text, on_chunk.as_ref()).await;
|
||||
// copy p..it to the beginning of the buffer
|
||||
buf.copy_within(p..it, 0);
|
||||
it -= p;
|
||||
@@ -1667,7 +1667,7 @@ async fn read_output(
|
||||
match err.error_len() {
|
||||
Some(p) => {
|
||||
// Invalid byte sequence: emit replacement and drop those bytes.
|
||||
emit_chunk(REPLACEMENT, on_chunk.as_ref());
|
||||
emit_chunk(REPLACEMENT, on_chunk.as_ref()).await;
|
||||
// copy p..it to the beginning of the buffer
|
||||
buf.copy_within(p..it, 0);
|
||||
it -= p;
|
||||
@@ -1688,10 +1688,10 @@ async fn read_output(
|
||||
for chunk in buf[..it].utf8_chunks() {
|
||||
let valid = chunk.valid();
|
||||
if !valid.is_empty() {
|
||||
emit_chunk(valid, on_chunk.as_ref());
|
||||
emit_chunk(valid, on_chunk.as_ref()).await;
|
||||
}
|
||||
if !chunk.invalid().is_empty() {
|
||||
emit_chunk(REPLACEMENT, on_chunk.as_ref());
|
||||
emit_chunk(REPLACEMENT, on_chunk.as_ref()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1777,7 +1777,7 @@ async fn read_output_buffered(
|
||||
while !pending.is_empty() {
|
||||
match str::from_utf8(&pending) {
|
||||
Ok(text) => {
|
||||
emit_chunk(text, Some(cb));
|
||||
emit_chunk(text, Some(cb)).await;
|
||||
pending.clear();
|
||||
break;
|
||||
},
|
||||
@@ -1786,12 +1786,12 @@ async fn read_output_buffered(
|
||||
if p > 0 {
|
||||
// SAFETY: [..p] is valid UTF-8 per valid_up_to().
|
||||
let text = unsafe { str::from_utf8_unchecked(&pending[..p]) };
|
||||
emit_chunk(text, Some(cb));
|
||||
emit_chunk(text, Some(cb)).await;
|
||||
pending.drain(..p);
|
||||
}
|
||||
match err.error_len() {
|
||||
Some(skip) => {
|
||||
emit_chunk(REPLACEMENT, Some(cb));
|
||||
emit_chunk(REPLACEMENT, Some(cb)).await;
|
||||
pending.drain(..skip);
|
||||
},
|
||||
None => break,
|
||||
@@ -1807,10 +1807,10 @@ async fn read_output_buffered(
|
||||
for chunk in pending.utf8_chunks() {
|
||||
let valid = chunk.valid();
|
||||
if !valid.is_empty() {
|
||||
emit_chunk(valid, Some(cb));
|
||||
emit_chunk(valid, Some(cb)).await;
|
||||
}
|
||||
if !chunk.invalid().is_empty() {
|
||||
emit_chunk(REPLACEMENT, Some(cb));
|
||||
emit_chunk(REPLACEMENT, Some(cb)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1858,9 +1858,16 @@ fn read_nonblocking<T: std::os::fd::AsRawFd>(file: &T, buf: &mut [u8]) -> io::Re
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_chunk(text: &str, callback: Option<&Sender<String>>) {
|
||||
/// Forward one decoded chunk to the streaming callback, honouring channel
|
||||
/// backpressure: on a bounded channel (the pi-natives JS bridge) the send
|
||||
/// parks until the consumer frees a slot — which parks the pipe reader and,
|
||||
/// transitively, the child on its stdout/stderr pipe — so a fast producer
|
||||
/// can never buffer unbounded output in memory (#4078). A disconnected
|
||||
/// receiver (consumer gone) fails immediately, so the pipe keeps draining
|
||||
/// and the child never wedges on a full pipe.
|
||||
async fn emit_chunk(text: &str, callback: Option<&Sender<String>>) {
|
||||
if let Some(callback) = callback {
|
||||
let _ = callback.send(text.to_string());
|
||||
let _ = callback.send_async(text.to_string()).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4140,4 +4147,39 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
|
||||
"builtin nohup masked SIGHUP like the external tool (output: {out:?})",
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression for #4078: the JS bridge hands the pipe readers a *bounded*
|
||||
/// chunk channel. With a consumer slower than the producer the readers
|
||||
/// must park on `send_async` (backpressuring the child through its pipe)
|
||||
/// rather than buffer unboundedly — and, unlike a drop-on-full design,
|
||||
/// every produced byte must still reach the consumer.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn streaming_output_backpressures_on_bounded_channel_without_loss() {
|
||||
const TOTAL_BYTES: usize = 1_048_576;
|
||||
let (tx, rx) = flume::bounded::<String>(4);
|
||||
let options = ShellExecuteOptions {
|
||||
command: format!("yes x | head -c {TOTAL_BYTES}"),
|
||||
..Default::default()
|
||||
};
|
||||
let run = tokio::spawn(execute_shell(options, Some(tx), CancelToken::default()));
|
||||
|
||||
let mut received = 0usize;
|
||||
while let Ok(chunk) = rx.recv_async().await {
|
||||
received += chunk.len();
|
||||
// Slow consumer: forces the bounded queue to fill and the readers
|
||||
// to park between chunks.
|
||||
time::sleep(Duration::from_micros(50)).await;
|
||||
}
|
||||
|
||||
let result = time::timeout(Duration::from_secs(30), run)
|
||||
.await
|
||||
.expect("command should finish despite backpressure")
|
||||
.expect("run task should not panic")
|
||||
.expect("execute should succeed");
|
||||
assert_eq!(result.exit_code, Some(0));
|
||||
assert!(!result.cancelled);
|
||||
assert!(!result.timed_out);
|
||||
assert_eq!(received, TOTAL_BYTES, "streamed bytes were dropped under backpressure");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,8 @@ A named profile (`omp --profile <name>`, the `--alias` shortcut, or `OMP_PROFILE
|
||||
|
||||
The relocation is uniform across the native provider (`builtin.ts`) and the generic `config.ts` helpers, so it covers slash commands, rules, prompts, instructions, hooks, tools, extensions, settings, skills, and MCP, plus the top-level `SYSTEM.md` / `RULES.md` / `AGENTS.md` files and runtime state (sessions, blobs, `agent.db`). A profile sees only its own OMP config, never the default profile's `~/.omp/agent`.
|
||||
|
||||
Keybindings are the one exception: a named profile merges the default profile's `~/.omp/agent/keybindings.*` under its own `~/.omp/profiles/<name>/agent/keybindings.*`, with the profile file overriding per binding ([#4867](https://github.com/can1357/oh-my-pi/issues/4867)). Keybindings describe the terminal/keyboard in front of the user, which doesn't change with the active profile, so user-level remaps keep working in every profile unless the profile explicitly overrides them. The inherited file is read-only for the profile process — legacy-format migration of the default profile's file only happens when the default profile itself runs.
|
||||
|
||||
The other source bases are not profile-scoped and load identically under every profile: the external-tool bases (`~/.claude`, `~/.codex`, `~/.gemini`) belong to those tools, and the project-level bases (`<cwd>/.omp`, `<cwd>/.claude`, ...) are keyed to the working directory. Throughout this document, read `~/.omp/agent` as shorthand for the active profile's agent directory.
|
||||
|
||||
## Important constraint
|
||||
|
||||
+4
-3
@@ -142,7 +142,7 @@ Also exposed:
|
||||
- `deliverAs: "nextTurn"` — stored and injected on the next user prompt
|
||||
- `triggerTurn: true` — starts a turn when idle (also honored with `deliverAs: "nextTurn"`: idle prompts immediately; while streaming the queued message schedules an internal continuation)
|
||||
|
||||
`pi.sendUserMessage(content, { deliverAs })` always goes through prompt flow; while streaming it queues as steer/follow-up.
|
||||
`pi.sendUserMessage(content, { deliverAs })` always goes through prompt flow. Omit `deliverAs` to start a normal prompt when idle; while streaming, omitted `deliverAs` queues the message as a steer. Set `deliverAs: "followUp"` to wait until the current run finishes.
|
||||
|
||||
## 2) Handler context (`ExtensionContext`)
|
||||
|
||||
@@ -311,6 +311,7 @@ Supported:
|
||||
|
||||
- dialogs: `select`, `confirm`, `input`, `editor`
|
||||
- input editing: `setEditorText`, `getEditorText`, `pasteToEditor`, `editor`
|
||||
- autocomplete stacking: `addAutocompleteProvider(factory)` wraps the built-in editor provider (factories apply in registration order and re-apply on every slash-command refresh)
|
||||
- terminal title and working message (`setTitle`, `setWorkingMessage`)
|
||||
- notifications/status/editor text/terminal input/custom overlays
|
||||
- theme listing/loading by name (`setTheme` supports string names)
|
||||
@@ -334,7 +335,7 @@ Unsupported/no-op in RPC implementation:
|
||||
|
||||
- `onTerminalInput`
|
||||
- `custom`
|
||||
- `setFooter`, `setHeader`, `setEditorComponent`
|
||||
- `setFooter`, `setHeader`, `setEditorComponent`, `addAutocompleteProvider`
|
||||
- `setWorkingMessage`
|
||||
- theme switching/loading (`setTheme` returns failure)
|
||||
- tool expansion controls are inert
|
||||
@@ -345,7 +346,7 @@ When no UI context is supplied to runner init, `ctx.hasUI` is `false` and method
|
||||
|
||||
### ACP mode
|
||||
|
||||
ACP installs an elicitation-bridged UI context (`createAcpExtensionUiContext` in `acp-agent.ts`). `ctx.hasUI` is `true` while only `select`/`confirm`/`input` round-trip (as ACP elicitations; defaults are returned when the client lacks the `elicitation.form` capability). The non-elicitation surface (widgets, editor, theming, terminal input) is stubbed no-op.
|
||||
ACP installs an elicitation-bridged UI context (`createAcpExtensionUiContext` in `acp-agent.ts`). `ctx.hasUI` is `true` while only `select`/`confirm`/`input` round-trip (as ACP elicitations; defaults are returned when the client lacks the `elicitation.form` capability). The non-elicitation surface (widgets, editor, theming, terminal input, autocomplete stacking) is stubbed no-op.
|
||||
|
||||
## Session and state patterns
|
||||
|
||||
|
||||
@@ -307,6 +307,7 @@ Our fork has architectural decisions that differ from upstream. **Do not port th
|
||||
| `FooterDataProvider` class | `StatusLineComponent` | Simpler, integrated status line |
|
||||
| `ctx.ui.setHeader()` / `ctx.ui.setFooter()` | No-op stubs in current extension contexts | Not currently wired to replace the TUI status/header UI |
|
||||
| `ctx.ui.setEditorComponent()` | Wired in interactive mode; no-op stubs in ACP/RPC/headless contexts | Custom editor replacement works in the interactive TUI; non-TUI runtimes keep stubs |
|
||||
| `ctx.ui.addAutocompleteProvider()` | Wired in interactive mode; no-op stubs in ACP/RPC/headless contexts | Factory wrapping matches upstream; omp's editor has no custom `triggerCharacters`, so wrapped providers surface at the built-in trigger points |
|
||||
| `InteractiveModeOptions` options object | Positional constructor args (options type still exported) | Keep constructor signature; update the type when upstream adds fields |
|
||||
|
||||
### Component Naming
|
||||
|
||||
+3
-2
@@ -215,8 +215,9 @@ Behavior:
|
||||
|
||||
1. optional command/template expansion (`/` commands, custom commands, file slash commands, prompt templates)
|
||||
2. if currently streaming:
|
||||
- requires `streamingBehavior: "steer" | "followUp"`
|
||||
- queues instead of throwing work away
|
||||
- `streamingBehavior: "steer" | "followUp"` chooses how `prompt()` queues
|
||||
- extension `sendUserMessage(content)` defaults to steer when `deliverAs` is omitted
|
||||
- queued messages are preserved instead of throwing work away
|
||||
3. if idle:
|
||||
- validates model + API key
|
||||
- appends user message
|
||||
|
||||
+12
-12
@@ -25,18 +25,18 @@
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@napi-rs/cli": "3.7.0",
|
||||
"@oh-my-pi/hashline": "16.3.12",
|
||||
"@oh-my-pi/omp-stats": "16.3.12",
|
||||
"@oh-my-pi/pi-agent-core": "16.3.12",
|
||||
"@oh-my-pi/pi-ai": "16.3.12",
|
||||
"@oh-my-pi/pi-catalog": "16.3.12",
|
||||
"@oh-my-pi/pi-coding-agent": "16.3.12",
|
||||
"@oh-my-pi/pi-mnemopi": "16.3.12",
|
||||
"@oh-my-pi/pi-natives": "16.3.12",
|
||||
"@oh-my-pi/pi-tui": "16.3.12",
|
||||
"@oh-my-pi/pi-utils": "16.3.12",
|
||||
"@oh-my-pi/pi-wire": "16.3.12",
|
||||
"@oh-my-pi/snapcompact": "16.3.12",
|
||||
"@oh-my-pi/hashline": "16.3.14",
|
||||
"@oh-my-pi/omp-stats": "16.3.14",
|
||||
"@oh-my-pi/pi-agent-core": "16.3.14",
|
||||
"@oh-my-pi/pi-ai": "16.3.14",
|
||||
"@oh-my-pi/pi-catalog": "16.3.14",
|
||||
"@oh-my-pi/pi-coding-agent": "16.3.14",
|
||||
"@oh-my-pi/pi-mnemopi": "16.3.14",
|
||||
"@oh-my-pi/pi-natives": "16.3.14",
|
||||
"@oh-my-pi/pi-tui": "16.3.14",
|
||||
"@oh-my-pi/pi-utils": "16.3.14",
|
||||
"@oh-my-pi/pi-wire": "16.3.14",
|
||||
"@oh-my-pi/snapcompact": "16.3.14",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/context-async-hooks": "^2.7.1",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-agent-core",
|
||||
"version": "16.3.12",
|
||||
"version": "16.3.14",
|
||||
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -2,6 +2,29 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [16.3.14] - 2026-07-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated Codex reasoning effort mapping to support shifted wire tiers for newer models
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the Codex Responses request transformer bypassing catalog/compat reasoning effort maps: the clamped user effort is now remapped to the provider wire tier (GPT-5.6's shifted five-tier scale sends `max` for user `xhigh` and `xhigh` for `high`), failing loudly if a map produces a value outside the Codex wire vocabulary.
|
||||
|
||||
## [16.3.13] - 2026-07-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the xAI Grok OAuth (`xai-oauth`) provider to use manual code-paste login by default. `/login` now accepts a pasted authorization code or full `http://127.0.0.1:56121/callback?code=...` redirect URL without starting a local callback listener ([#3277](https://github.com/can1357/oh-my-pi/pull/3277) by [@Jaaneek](https://github.com/Jaaneek)).
|
||||
- Renamed the xAI Grok OAuth provider in login and credential prompts to "xAI Grok OAuth (SuperGrok or X Premium+)" ([#3277](https://github.com/can1357/oh-my-pi/pull/3277) by [@Jaaneek](https://github.com/Jaaneek)).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the generic lazy-stream idle watchdog aborting healthy `cursor-agent` streams with "Provider stream stalled while waiting for the next event" while a Cursor exec-channel local tool (shell/read/grep/write/MCP/…) legitimately ran longer than the idle budget. Provider streams now advertise consumer-side local work in flight and the watchdog slides its deadline instead of aborting; genuinely silent streams still time out. ([#4593](https://github.com/can1357/oh-my-pi/issues/4593))
|
||||
- Fixed OpenAI Codex/Responses reasoning streams so streamed thinking content is preserved when the final `output_item.done` reconstructs to an empty summary ([#4918](https://github.com/can1357/oh-my-pi/issues/4918)).
|
||||
- Fixed Anthropic streams hanging forever when generation wedges mid-stream (notably long `write` tool calls on Opus 4.8 high/xhigh) while the server keeps sending `ping` keepalives: pings now extend the idle watchdog only within a bounded window (3x the idle timeout) since the last real stream event, so a stalled tool-call stream times out and recovers instead of hanging with no retry path ([#4900](https://github.com/can1357/oh-my-pi/issues/4900)).
|
||||
|
||||
## [16.3.12] - 2026-07-08
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-ai",
|
||||
"version": "16.3.12",
|
||||
"version": "16.3.14",
|
||||
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Broker-aware auth-storage discovery used by both the coding-agent runtime and
|
||||
* the catalog model generator. Keeps the precedence logic (env → config.yml →
|
||||
* the catalog model generator. Keeps the precedence logic (env → config.yml/config.yaml →
|
||||
* token file → local SQLite) in one place so build-time tooling sees the same
|
||||
* credentials as the TUI.
|
||||
*/
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
getConfigRootDir,
|
||||
isEnoent,
|
||||
logger,
|
||||
MAIN_CONFIG_FILENAMES,
|
||||
} from "@oh-my-pi/pi-utils";
|
||||
import { YAML } from "bun";
|
||||
import { AuthStorage } from "../auth-storage";
|
||||
@@ -72,21 +73,24 @@ interface ConfigSnapshot {
|
||||
}
|
||||
|
||||
async function readConfigYaml(agentDir: string): Promise<ConfigSnapshot> {
|
||||
const configPath = path.join(agentDir, "config.yml");
|
||||
try {
|
||||
const raw = await Bun.file(configPath).text();
|
||||
const parsed = YAML.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const url = typeof record["auth.broker.url"] === "string" ? (record["auth.broker.url"] as string) : undefined;
|
||||
const token =
|
||||
typeof record["auth.broker.token"] === "string" ? (record["auth.broker.token"] as string) : undefined;
|
||||
return { url, token };
|
||||
} catch (err) {
|
||||
if (isEnoent(err)) return {};
|
||||
logger.warn("auth-broker config.yml unreadable", { error: String(err) });
|
||||
return {};
|
||||
for (const filename of MAIN_CONFIG_FILENAMES) {
|
||||
const configPath = path.join(agentDir, filename);
|
||||
try {
|
||||
const raw = await Bun.file(configPath).text();
|
||||
const parsed = YAML.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const url = typeof record["auth.broker.url"] === "string" ? (record["auth.broker.url"] as string) : undefined;
|
||||
const token =
|
||||
typeof record["auth.broker.token"] === "string" ? (record["auth.broker.token"] as string) : undefined;
|
||||
return { url, token };
|
||||
} catch (err) {
|
||||
if (isEnoent(err)) continue;
|
||||
logger.warn("auth-broker config unreadable", { path: configPath, error: String(err) });
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function resolveSnapshotTtlMs(): number {
|
||||
@@ -104,7 +108,7 @@ function resolveSnapshotTtlMs(): number {
|
||||
* Resolve broker connection configuration using the same precedence as the TUI:
|
||||
*
|
||||
* 1. `OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN` env vars.
|
||||
* 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml`.
|
||||
* 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml` or `<agentDir>/config.yaml`.
|
||||
* 3. `<config-root>/auth-broker.token` file (paired with a URL from env/config).
|
||||
*
|
||||
* Returns `null` when no broker URL is configured — callers should fall back to
|
||||
|
||||
@@ -1462,6 +1462,16 @@ async function* observeDecodedAnthropicSdkEvents(
|
||||
|
||||
const PROVIDER_MAX_RETRIES = 10;
|
||||
|
||||
/**
|
||||
* How long `ping` keepalives may keep extending the idle deadline without any
|
||||
* semantic stream progress, as a multiple of the idle timeout. Anthropic pings
|
||||
* across legitimate generation gaps, so pings count as liveness — but a wedged
|
||||
* upstream that pings forever while producing no events must eventually trip
|
||||
* the idle watchdog instead of hanging an active tool-call stream without a
|
||||
* recovery path (#4900).
|
||||
*/
|
||||
const PING_PROGRESS_MAX_IDLE_MULTIPLIER = 3;
|
||||
|
||||
/**
|
||||
* Log a malformed-stream-envelope anomaly without aborting the turn. The strict
|
||||
* parser would `throw new AnthropicStreamEnvelopeError(...)` here; we instead
|
||||
@@ -2007,11 +2017,20 @@ const streamAnthropicOnce = (
|
||||
}
|
||||
>();
|
||||
|
||||
// Pings keep the idle deadline alive once content is flowing, but a
|
||||
// ping before message_start must not consume the first-event watchdog:
|
||||
// it would flip the (retryable) pre-content stall classification into
|
||||
// a terminal mid-stream idle timeout.
|
||||
// Pings keep the idle deadline alive once content is flowing (Anthropic
|
||||
// bridges legitimate generation gaps with keepalives), but only within a
|
||||
// bounded window: a wedged upstream that pings forever while the model
|
||||
// produces nothing must still trip the idle watchdog, otherwise an
|
||||
// active tool-call stream hangs unrecoverably with no retry (#4900).
|
||||
// A ping before message_start must not consume the first-event watchdog
|
||||
// either: it would flip the (retryable) pre-content stall classification
|
||||
// into a terminal mid-stream idle timeout.
|
||||
let sawNonPingEvent = false;
|
||||
let lastNonPingProgressAtMs = 0;
|
||||
const pingProgressCapMs =
|
||||
idleTimeoutMs !== undefined && idleTimeoutMs > 0
|
||||
? idleTimeoutMs * PING_PROGRESS_MAX_IDLE_MULTIPLIER
|
||||
: undefined;
|
||||
const timedAnthropicStream = iterateWithIdleTimeout(anthropicStream, {
|
||||
idleTimeoutMs,
|
||||
firstItemTimeoutMs: firstEventTimeoutMs,
|
||||
@@ -2021,8 +2040,13 @@ const streamAnthropicOnce = (
|
||||
onFirstItemTimeout: () => activeAbortTracker.abortLocally(firstEventTimeoutAbortError),
|
||||
abortSignal: options?.signal,
|
||||
isProgressItem: item => {
|
||||
if ((item as AnthropicStreamEvent).type === "ping") return sawNonPingEvent;
|
||||
if ((item as AnthropicStreamEvent).type === "ping") {
|
||||
if (!sawNonPingEvent) return false;
|
||||
if (pingProgressCapMs === undefined) return true;
|
||||
return Date.now() - lastNonPingProgressAtMs < pingProgressCapMs;
|
||||
}
|
||||
sawNonPingEvent = true;
|
||||
lastNonPingProgressAtMs = Date.now();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -653,7 +653,8 @@ export interface UsageState {
|
||||
sawTokenDelta: boolean;
|
||||
}
|
||||
|
||||
async function handleServerMessage(
|
||||
/** Exported for tests: drives one Cursor server message through the stream (exec waits mark the stream busy). */
|
||||
export async function handleServerMessage(
|
||||
msg: AgentServerMessage,
|
||||
output: AssistantMessage,
|
||||
stream: AssistantMessageEventStream,
|
||||
@@ -675,15 +676,21 @@ async function handleServerMessage(
|
||||
} else if (msgCase === "kvServerMessage") {
|
||||
handleKvServerMessage(msg.message.value as KvServerMessage, blobStore, h2Request);
|
||||
} else if (msgCase === "execServerMessage") {
|
||||
await handleExecServerMessage(
|
||||
msg.message.value as ExecServerMessage,
|
||||
h2Request,
|
||||
execHandlers,
|
||||
onToolResult,
|
||||
requestContextTools,
|
||||
output,
|
||||
stream,
|
||||
state,
|
||||
// The server is waiting on OUR local tool result during this window — no
|
||||
// AssistantMessageEvent flows until the handler finishes. Mark the wait
|
||||
// as local work so the lazy stream idle watchdog attributes the silence
|
||||
// to the tool run instead of aborting a healthy stream (issue #4593).
|
||||
await stream.trackLocalWork(
|
||||
handleExecServerMessage(
|
||||
msg.message.value as ExecServerMessage,
|
||||
h2Request,
|
||||
execHandlers,
|
||||
onToolResult,
|
||||
requestContextTools,
|
||||
output,
|
||||
stream,
|
||||
state,
|
||||
),
|
||||
);
|
||||
} else if (msgCase === "conversationCheckpointUpdate") {
|
||||
handleConversationCheckpointUpdate(msg.message.value, output, usageState, onConversationCheckpoint);
|
||||
|
||||
@@ -95,6 +95,7 @@ import {
|
||||
encodeTextSignatureV1,
|
||||
finalizeCustomToolCallInputDone,
|
||||
finalizePendingResponsesToolCalls,
|
||||
finalizeReasoningThinking,
|
||||
finalizeToolCallArgumentsDone,
|
||||
isOpenAIResponsesProgressEvent,
|
||||
mapOpenAIResponsesStopReason,
|
||||
@@ -1407,6 +1408,21 @@ class CodexStreamProcessor {
|
||||
return firstTokenTime;
|
||||
}
|
||||
|
||||
if (eventType === "response.reasoning_text.delta") {
|
||||
const entry = this.runtime.openItemForEvent(rawEvent);
|
||||
const delta = typeof rawEvent.delta === "string" ? rawEvent.delta : "";
|
||||
if (entry?.item.type === "reasoning" && entry.block?.type === "thinking") {
|
||||
entry.block.thinking += delta;
|
||||
stream.push({
|
||||
type: "thinking_delta",
|
||||
contentIndex: entry.contentIndex,
|
||||
delta,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
return firstTokenTime;
|
||||
}
|
||||
|
||||
if (eventType === "response.reasoning_summary_part.done") {
|
||||
if (this.runtime.currentItem?.type === "reasoning" && this.runtime.currentBlock?.type === "thinking") {
|
||||
appendReasoningSummaryPartDone(
|
||||
@@ -1522,13 +1538,13 @@ class CodexStreamProcessor {
|
||||
// most-recently-added block may belong to a sibling (#2619). Some Codex
|
||||
// function/custom tool items omit `id`; in that case `output_index` still
|
||||
// routes `output_item.done` to the block that received `output_item.added`.
|
||||
const itemId = typeof (item as { id?: string }).id === "string" ? (item as { id: string }).id : "";
|
||||
const itemId = "id" in item && typeof item.id === "string" ? item.id : "";
|
||||
const entry = (itemId ? runtime.openItems.get(itemId) : null) ?? runtime.openItemForEvent(rawEvent);
|
||||
const block = entry?.block ?? null;
|
||||
const contentIndex = entry?.contentIndex ?? output.content.length - 1;
|
||||
|
||||
if (item.type === "reasoning" && block?.type === "thinking") {
|
||||
block.thinking = item.summary?.map(summary => summary.text).join("\n\n") || "";
|
||||
block.thinking = finalizeReasoningThinking(item, block.thinking);
|
||||
block.thinkingSignature = JSON.stringify(item);
|
||||
stream.push({
|
||||
type: "thinking_end",
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
import type { Effort } from "@oh-my-pi/pi-catalog/effort";
|
||||
import { Effort } from "@oh-my-pi/pi-catalog/effort";
|
||||
import { supportsAllTurnsReasoningContext, supportsCodexReasoningSummary } from "@oh-my-pi/pi-catalog/identity";
|
||||
import { requireSupportedEffort } from "@oh-my-pi/pi-catalog/model-thinking";
|
||||
import type { Api, Model } from "../../types";
|
||||
import type { Model } from "../../types";
|
||||
import { mapOpenAIReasoningEffort } from "../openai-shared";
|
||||
|
||||
/** Reasoning replay scope for the Codex Responses API (`reasoning.context`). */
|
||||
export type CodexReasoningContext = "auto" | "current_turn" | "all_turns";
|
||||
|
||||
/** User-facing effort levels accepted by Codex request options. */
|
||||
type CodexCallerEffort = "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
|
||||
/** Caller literal → catalog `Effort` bridge (the enum is nominal). */
|
||||
const EFFORT_BY_NAME: Record<CodexCallerEffort, Effort> = {
|
||||
minimal: Effort.Minimal,
|
||||
low: Effort.Low,
|
||||
medium: Effort.Medium,
|
||||
high: Effort.High,
|
||||
xhigh: Effort.XHigh,
|
||||
};
|
||||
|
||||
export interface ReasoningConfig {
|
||||
effort: "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
effort: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
||||
summary?: "auto" | "concise" | "detailed";
|
||||
context?: CodexReasoningContext;
|
||||
}
|
||||
|
||||
export interface CodexRequestOptions {
|
||||
reasoningEffort?: ReasoningConfig["effort"];
|
||||
/** User-facing effort; the wire-only `max` tier is reached via the model's effort map. */
|
||||
reasoningEffort?: CodexCallerEffort | "none";
|
||||
reasoningSummary?: ReasoningConfig["summary"] | null;
|
||||
/** Explicit `reasoning.context` override; defaults to `all_turns` when unset. The `all_turns` value is gated to gpt-5.4+ Codex models — older ids reject it, so it is suppressed and `context` omitted. */
|
||||
reasoningContext?: CodexReasoningContext;
|
||||
@@ -80,10 +94,40 @@ export function shouldUseCodexResponsesLite(body: RequestBody, requested: boolea
|
||||
return requested === true && !containsInputImage(body.input);
|
||||
}
|
||||
|
||||
function getReasoningConfig(model: Model<Api>, options: CodexRequestOptions): ReasoningConfig {
|
||||
/**
|
||||
* Clamp a user-facing effort to the model's ladder, then remap to the wire
|
||||
* tier (e.g. GPT-5.6's shifted five-tier scale sends `max` for user `xhigh`).
|
||||
* A mapped value outside the Codex wire vocabulary is a broken compat/model
|
||||
* effort map — fail loudly rather than silently sending a different tier.
|
||||
*/
|
||||
function mapCodexWireEffort(
|
||||
model: Model<"openai-codex-responses">,
|
||||
effort: CodexCallerEffort,
|
||||
): ReasoningConfig["effort"] {
|
||||
const mapped = mapOpenAIReasoningEffort(model, model.compat, requireSupportedEffort(model, EFFORT_BY_NAME[effort]));
|
||||
switch (mapped) {
|
||||
case "none":
|
||||
case "minimal":
|
||||
case "low":
|
||||
case "medium":
|
||||
case "high":
|
||||
case "xhigh":
|
||||
case "max":
|
||||
return mapped;
|
||||
default:
|
||||
throw new Error(
|
||||
`Effort map for ${model.provider}/${model.id} produced invalid Codex reasoning effort "${mapped}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getReasoningConfig(
|
||||
model: Model<"openai-codex-responses">,
|
||||
effort: NonNullable<CodexRequestOptions["reasoningEffort"]>,
|
||||
options: CodexRequestOptions,
|
||||
): ReasoningConfig {
|
||||
const config: ReasoningConfig = {
|
||||
effort:
|
||||
options.reasoningEffort === "none" ? "none" : requireSupportedEffort(model, options.reasoningEffort as Effort),
|
||||
effort: effort === "none" ? "none" : mapCodexWireEffort(model, effort),
|
||||
};
|
||||
// `reasoning.summary` is accepted only from gpt-5.4 onward; earlier Codex ids
|
||||
// (gpt-5.1-codex, gpt-5.3-codex, gpt-5.3-codex-spark) reject it with
|
||||
@@ -216,7 +260,7 @@ function stripImageDetails(input: InputItem[]): void {
|
||||
|
||||
export async function transformRequestBody(
|
||||
body: RequestBody,
|
||||
model: Model<Api>,
|
||||
model: Model<"openai-codex-responses">,
|
||||
options: CodexRequestOptions = {},
|
||||
prompt?: { developerMessages: string[] },
|
||||
): Promise<RequestBody> {
|
||||
@@ -300,7 +344,7 @@ export async function transformRequestBody(
|
||||
}
|
||||
|
||||
if (options.reasoningEffort !== undefined) {
|
||||
const reasoningConfig = getReasoningConfig(model, options);
|
||||
const reasoningConfig = getReasoningConfig(model, options.reasoningEffort, options);
|
||||
body.reasoning = {
|
||||
...body.reasoning,
|
||||
...reasoningConfig,
|
||||
|
||||
@@ -695,13 +695,19 @@ export interface OpenAICompatPolicy {
|
||||
};
|
||||
}
|
||||
|
||||
function mapOpenAIReasoningEffort(
|
||||
/**
|
||||
* Map a user-facing effort to the provider wire value: explicit compat
|
||||
* override first, then the model's baked `thinking.effortMap`, else identity.
|
||||
* Shared by the chat-completions/Responses policy resolver and the Codex
|
||||
* request transformer.
|
||||
*/
|
||||
export function mapOpenAIReasoningEffort(
|
||||
model: Pick<Model, "thinking">,
|
||||
compat: OpenAICompatPolicyCompat,
|
||||
compat: { reasoningEffortMap?: Partial<Record<Effort, string>> } | undefined,
|
||||
effort: string,
|
||||
): string {
|
||||
const level = effort as Effort;
|
||||
return compat.reasoningEffortMap?.[level] ?? model.thinking?.effortMap?.[level] ?? effort;
|
||||
return compat?.reasoningEffortMap?.[level] ?? model.thinking?.effortMap?.[level] ?? effort;
|
||||
}
|
||||
|
||||
function isImplicitDisableWhenNotRequested(disableMode: OpenAIReasoningDisableMode): boolean {
|
||||
@@ -1684,6 +1690,14 @@ export function appendReasoningSummaryPart(
|
||||
item.summary.push(part);
|
||||
}
|
||||
|
||||
/** Chooses the final reasoning text without discarding content already streamed into the block. */
|
||||
export function finalizeReasoningThinking(item: ResponseReasoningItem, streamedThinking: string): string {
|
||||
const summaryThinking = item.summary?.map(part => part.text).join("\n\n") ?? "";
|
||||
if (summaryThinking) return summaryThinking;
|
||||
const contentThinking = item.content?.[0]?.type === "reasoning_text" ? (item.content[0].text ?? "") : "";
|
||||
return contentThinking || streamedThinking || "";
|
||||
}
|
||||
|
||||
export function appendReasoningSummaryTextDelta(
|
||||
item: ResponseReasoningItem,
|
||||
block: ThinkingContent,
|
||||
@@ -2208,12 +2222,6 @@ export async function processResponsesStream<TApi extends Api>(
|
||||
? lookupOpenItem({ output_index: event.output_index, item_id: item.id ?? item.call_id })
|
||||
: lookupOpenItem({ output_index: event.output_index, item_id: item.id });
|
||||
if (item.type === "reasoning") {
|
||||
const thinking =
|
||||
item.summary?.length > 0
|
||||
? item.summary.map(part => part.text).join("\n\n")
|
||||
: item.content?.[0]?.type === "reasoning_text"
|
||||
? (item.content[0].text ?? "")
|
||||
: "";
|
||||
// Prefer the routed entry; the bare itemId find misroutes when ids are
|
||||
// absent (`undefined === undefined` matches the FIRST thinking block) and
|
||||
// misses entirely when the done-event id drifts from the added-event id.
|
||||
@@ -2224,12 +2232,12 @@ export async function processResponsesStream<TApi extends Api>(
|
||||
| ThinkingContent
|
||||
| undefined);
|
||||
if (reasoningBlock) {
|
||||
reasoningBlock.thinking = thinking;
|
||||
reasoningBlock.thinking = finalizeReasoningThinking(item, reasoningBlock.thinking);
|
||||
reasoningBlock.thinkingSignature = JSON.stringify(item);
|
||||
stream.push({
|
||||
type: "thinking_end",
|
||||
contentIndex: contentIndexOf(reasoningBlock),
|
||||
content: thinking,
|
||||
content: reasoningBlock.thinking,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -157,6 +157,7 @@ let openAICompletionsProviderModulePromise: Promise<LazyProviderModule<"openai-c
|
||||
let openAIResponsesProviderModulePromise: Promise<LazyProviderModule<"openai-responses">> | undefined;
|
||||
let ollamaProviderModulePromise: Promise<LazyProviderModule<"ollama-chat">> | undefined;
|
||||
let cursorProviderModulePromise: Promise<LazyProviderModule<"cursor-agent">> | undefined;
|
||||
let cursorProviderModuleOverride: LazyProviderModule<"cursor-agent"> | undefined;
|
||||
let devinProviderModulePromise: Promise<LazyProviderModule<"devin-agent">> | undefined;
|
||||
let bedrockProviderModuleOverride: LazyProviderModule<"bedrock-converse-stream"> | undefined;
|
||||
let bedrockProviderModulePromise: Promise<LazyProviderModule<"bedrock-converse-stream">> | undefined;
|
||||
@@ -167,6 +168,12 @@ export function setBedrockProviderModule(module: BedrockProviderModule): void {
|
||||
};
|
||||
}
|
||||
|
||||
export function setCursorProviderModule(module: CursorProviderModule): void {
|
||||
cursorProviderModuleOverride = {
|
||||
stream: module.streamCursor,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stream forwarding / error helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -245,6 +252,10 @@ function forwardStream<TApi extends Api>(
|
||||
(limits?.openAIIdleEnvFloorsFirstEvent
|
||||
? getOpenAIStreamFirstEventTimeoutMs(idleTimeoutMs, limits.defaultFirstEventTimeoutMs)
|
||||
: getStreamFirstEventTimeoutMs(idleTimeoutMs, limits?.defaultFirstEventTimeoutMs)));
|
||||
// Providers with a server-driven local tool bridge (e.g. the Cursor
|
||||
// exec channel) mark their stream busy while a local tool runs; the
|
||||
// watchdog must not read that silence as a provider stall (#4593).
|
||||
const localWorkSource = source instanceof EventStreamImpl ? source : undefined;
|
||||
const watchedSource = iterateWithIdleTimeout(source, {
|
||||
idleTimeoutMs,
|
||||
firstItemTimeoutMs,
|
||||
@@ -260,6 +271,7 @@ function forwardStream<TApi extends Api>(
|
||||
// `idleTimeoutMs` while we're still legitimately waiting on the model's
|
||||
// first response (slow first-token from reasoning models, cold proxies, etc.).
|
||||
isProgressItem: event => (event as AssistantMessageEvent).type !== "start",
|
||||
hasPendingLocalWork: localWorkSource ? () => localWorkSource.hasPendingLocalWork : undefined,
|
||||
});
|
||||
|
||||
for await (const event of watchedSource) {
|
||||
@@ -411,6 +423,9 @@ function loadOllamaProviderModule(): Promise<LazyProviderModule<"ollama-chat">>
|
||||
}
|
||||
|
||||
function loadCursorProviderModule(): Promise<LazyProviderModule<"cursor-agent">> {
|
||||
if (cursorProviderModuleOverride) {
|
||||
return Promise.resolve(cursorProviderModuleOverride);
|
||||
}
|
||||
cursorProviderModulePromise ||= import("./cursor").then(module => {
|
||||
const provider = module as CursorProviderModule;
|
||||
return { stream: provider.streamCursor };
|
||||
|
||||
@@ -69,6 +69,61 @@ describe("XAIOAuthFlow", () => {
|
||||
|
||||
expect(flow.redirectUri).toBe("http://127.0.0.1:56121/callback");
|
||||
});
|
||||
|
||||
it("uses pasted-code login without starting a callback server", async () => {
|
||||
const serveSpy = vi.spyOn(Bun, "serve").mockImplementation(() => {
|
||||
throw new Error("callback server should not start");
|
||||
});
|
||||
let authUrl = "";
|
||||
let tokenRequestBody = "";
|
||||
const progress: string[] = [];
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input instanceof Request ? input.url : input.toString();
|
||||
if (url.includes("/.well-known/openid-configuration")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
authorization_endpoint: "https://auth.x.ai/oauth/authorize",
|
||||
token_endpoint: "https://auth.x.ai/oauth/token",
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
tokenRequestBody = init?.body instanceof URLSearchParams ? init.body.toString() : String(init?.body ?? "");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
});
|
||||
|
||||
const flow = new XAIOAuthFlow({
|
||||
fetch: fetchMock as unknown as typeof fetch,
|
||||
onAuth: info => {
|
||||
authUrl = info.url;
|
||||
},
|
||||
onManualCodeInput: async () => {
|
||||
const parsed = new URL(authUrl);
|
||||
const redirectUri = parsed.searchParams.get("redirect_uri") ?? "";
|
||||
const state = parsed.searchParams.get("state") ?? "";
|
||||
return `${redirectUri}?code=code-xyz&state=${encodeURIComponent(state)}`;
|
||||
},
|
||||
onProgress: message => progress.push(message),
|
||||
});
|
||||
|
||||
const credentials = await flow.login();
|
||||
const authorizeUrl = new URL(authUrl);
|
||||
const tokenParams = new URLSearchParams(tokenRequestBody);
|
||||
|
||||
expect(serveSpy).not.toHaveBeenCalled();
|
||||
expect(authorizeUrl.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:56121/callback");
|
||||
expect(progress).toContain("Waiting for pasted authorization code...");
|
||||
expect(tokenParams.get("code")).toBe("code-xyz");
|
||||
expect(credentials.access).toBe("access-token");
|
||||
expect(credentials.refresh).toBe("refresh-token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("XAIOAuthFlow.exchangeToken", () => {
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface OAuthCallbackFlowOptions {
|
||||
* an actionable message before opening the browser.
|
||||
*/
|
||||
allowPortFallback?: boolean;
|
||||
/** Skip the local callback server entirely; the user pastes the code or redirect URL back. */
|
||||
manualInputOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,6 +62,7 @@ export abstract class OAuthCallbackFlow {
|
||||
callbackHostname: string;
|
||||
redirectUri?: string;
|
||||
allowPortFallback: boolean;
|
||||
#manualInputOnly: boolean;
|
||||
#callbackResolve?: (result: CallbackResult) => void;
|
||||
#callbackReject?: (error: string) => void;
|
||||
/**
|
||||
@@ -82,6 +85,7 @@ export abstract class OAuthCallbackFlow {
|
||||
this.callbackPath = callbackPath;
|
||||
this.callbackHostname = DEFAULT_HOSTNAME;
|
||||
this.allowPortFallback = true;
|
||||
this.#manualInputOnly = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -90,6 +94,7 @@ export abstract class OAuthCallbackFlow {
|
||||
this.callbackHostname = preferredPortOrOptions.callbackHostname ?? DEFAULT_HOSTNAME;
|
||||
this.redirectUri = preferredPortOrOptions.redirectUri;
|
||||
this.allowPortFallback = preferredPortOrOptions.allowPortFallback ?? true;
|
||||
this.#manualInputOnly = preferredPortOrOptions.manualInputOnly ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,8 +140,12 @@ export abstract class OAuthCallbackFlow {
|
||||
const state = this.generateState();
|
||||
this.#throwIfCancelled();
|
||||
|
||||
// Start callback server first to get actual redirect URI
|
||||
const { server, redirectUri, launchUrl } = await this.#startCallbackServer(state);
|
||||
// Start callback server first to get actual redirect URI. Manual-only
|
||||
// flows never bind a server — the advertised redirect URI is fixed and
|
||||
// the user pastes the code/redirect URL back instead.
|
||||
const { server, redirectUri, launchUrl } = this.#manualInputOnly
|
||||
? { server: undefined, redirectUri: this.#buildRedirectUri(), launchUrl: undefined }
|
||||
: await this.#startCallbackServer(state);
|
||||
|
||||
try {
|
||||
this.#throwIfCancelled();
|
||||
@@ -152,9 +161,12 @@ export abstract class OAuthCallbackFlow {
|
||||
|
||||
// Notify controller that auth is ready
|
||||
this.ctrl.onAuth?.({ url: authUrl, launchUrl, instructions });
|
||||
this.ctrl.onProgress?.("Waiting for browser authentication...");
|
||||
this.ctrl.onProgress?.(
|
||||
this.#manualInputOnly
|
||||
? "Waiting for pasted authorization code..."
|
||||
: "Waiting for browser authentication...",
|
||||
);
|
||||
|
||||
// Wait for callback or manual input
|
||||
const { code } = await this.#waitForCallback(state);
|
||||
this.#throwIfCancelled();
|
||||
|
||||
@@ -163,10 +175,14 @@ export abstract class OAuthCallbackFlow {
|
||||
return await this.exchangeToken(code, state, redirectUri);
|
||||
} finally {
|
||||
this.#pendingAuthUrl = undefined;
|
||||
server.stop();
|
||||
server?.stop();
|
||||
}
|
||||
}
|
||||
|
||||
#buildRedirectUri(): string {
|
||||
return this.redirectUri ?? `http://${this.callbackHostname}:${this.preferredPort}${this.callbackPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start callback server, trying preferred port first, falling back to random.
|
||||
* `launchUrl` is `undefined` when the caller configured `callbackPath` to
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// Ported from NousResearch/hermes-agent (MIT) — hermes_cli/auth.py xAI sections (L93-111, L2979-3160, L5286-5469).
|
||||
|
||||
/**
|
||||
* xAI Grok (SuperGrok Subscription) OAuth flow.
|
||||
* xAI Grok (SuperGrok or X Premium+) OAuth flow.
|
||||
*
|
||||
* Loopback PKCE flow on `127.0.0.1:56121/callback`. One token unlocks Grok-4.x
|
||||
* Manual-code PKCE flow using `127.0.0.1:56121/callback` as the allowlisted
|
||||
* redirect URI. One token unlocks Grok-4.x
|
||||
* chat, Grok Imagine image generation, and Grok Voice TTS via subsequent
|
||||
* commits. Endpoint discovery is hardened against MITM via
|
||||
* {@link validateXAIEndpoint}: any non-HTTPS or non-`x.ai`/`*.x.ai` host is
|
||||
@@ -196,10 +197,7 @@ function buildXAIAuthorizeUrl(opts: BuildXAIAuthorizeUrlOptions): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* xAI Grok OAuth loopback flow (Hermes `_xai_oauth_loopback_login` L5315-5469).
|
||||
*
|
||||
* Uses a fixed redirect URI so the callback server fails fast instead of
|
||||
* falling back to a random port that xAI's redirect_uri allowlist rejects.
|
||||
* xAI Grok OAuth code flow (Hermes `_xai_oauth_loopback_login` L5315-5469).
|
||||
*/
|
||||
export class XAIOAuthFlow extends OAuthCallbackFlow {
|
||||
#verifier: string = "";
|
||||
@@ -211,6 +209,7 @@ export class XAIOAuthFlow extends OAuthCallbackFlow {
|
||||
callbackPath: XAI_OAUTH_REDIRECT_PATH,
|
||||
callbackHostname: XAI_OAUTH_REDIRECT_HOST,
|
||||
redirectUri: `http://${XAI_OAUTH_REDIRECT_HOST}:${XAI_OAUTH_REDIRECT_PORT}${XAI_OAUTH_REDIRECT_PATH}`,
|
||||
manualInputOnly: true,
|
||||
} satisfies OAuthCallbackFlowOptions);
|
||||
this.#fetch = ctrl.fetch ?? fetch;
|
||||
}
|
||||
@@ -231,7 +230,7 @@ export class XAIOAuthFlow extends OAuthCallbackFlow {
|
||||
|
||||
return {
|
||||
url,
|
||||
instructions: `Complete login in your browser for xAI Grok (SuperGrok). Docs: ${XAI_OAUTH_DOCS_URL}`,
|
||||
instructions: `Complete login in your browser for xAI Grok (SuperGrok or X Premium+). Docs: ${XAI_OAUTH_DOCS_URL}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -308,9 +307,6 @@ export class XAIOAuthFlow extends OAuthCallbackFlow {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with xAI Grok OAuth (SuperGrok Subscription).
|
||||
*/
|
||||
export async function loginXAIOAuth(ctrl: OAuthController): Promise<OAuthCredentials> {
|
||||
return new XAIOAuthFlow(ctrl).login();
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ProviderDefinition } from "./types";
|
||||
|
||||
export const xaiOauthProvider = {
|
||||
id: "xai-oauth",
|
||||
name: "xAI Grok OAuth (SuperGrok Subscription)",
|
||||
name: "xAI Grok OAuth (SuperGrok or X Premium+)",
|
||||
login: async (cb: OAuthLoginCallbacks) => {
|
||||
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
|
||||
const { loginXAIOAuth } = await import("./oauth/xai-oauth");
|
||||
@@ -14,4 +14,5 @@ export const xaiOauthProvider = {
|
||||
const { refreshXAIOAuthToken } = await import("./oauth/xai-oauth");
|
||||
return refreshXAIOAuthToken(credentials.refresh);
|
||||
},
|
||||
pasteCodeFlow: true,
|
||||
} as const satisfies ProviderDefinition;
|
||||
|
||||
@@ -10,6 +10,14 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
|
||||
resultSettled = false;
|
||||
#failed = false;
|
||||
#error: unknown = undefined;
|
||||
/**
|
||||
* Consumer-side local operations currently in flight for this stream — a
|
||||
* provider transport waiting on a server-requested local tool bridge
|
||||
* (e.g. the Cursor exec channel) before it can send the result upstream.
|
||||
* While non-zero, event silence is attributable to our own pending work,
|
||||
* not a provider stall; idle watchdogs consult {@link hasPendingLocalWork}.
|
||||
*/
|
||||
#pendingLocalWork = 0;
|
||||
finalResultPromise: Promise<R>;
|
||||
resolveFinalResult!: (result: R) => void;
|
||||
rejectFinalResult!: (err: unknown) => void;
|
||||
@@ -116,6 +124,24 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
|
||||
result(): Promise<R> {
|
||||
return this.finalResultPromise;
|
||||
}
|
||||
|
||||
/** True while local work tracked via {@link trackLocalWork} is pending. */
|
||||
get hasPendingLocalWork(): boolean {
|
||||
return this.#pendingLocalWork > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a local-work promise so idle watchdogs on this stream do not treat
|
||||
* the event silence while it is pending as a provider stall.
|
||||
*/
|
||||
async trackLocalWork<TWork>(work: Promise<TWork>): Promise<TWork> {
|
||||
this.#pendingLocalWork++;
|
||||
try {
|
||||
return await work;
|
||||
} finally {
|
||||
this.#pendingLocalWork--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class AssistantMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
||||
|
||||
@@ -135,6 +135,16 @@ export interface IdleTimeoutIteratorOptions {
|
||||
* keepalive/no-op events from keeping a stalled tool call alive forever.
|
||||
*/
|
||||
isProgressItem?: (item: unknown) => boolean;
|
||||
/**
|
||||
* Reports consumer-side local work in flight for the stream: the provider
|
||||
* transport is waiting on a server-requested local tool bridge (e.g. the
|
||||
* Cursor exec channel) before anything can flow upstream again. While it
|
||||
* returns true, an expired idle / first-item deadline slides forward
|
||||
* instead of aborting — the silence is ours, not a provider stall. The
|
||||
* watchdog re-arms with a full budget once the local work completes, so a
|
||||
* provider that stalls afterwards is still caught.
|
||||
*/
|
||||
hasPendingLocalWork?: () => boolean;
|
||||
/**
|
||||
* Cancel iteration as soon as this signal aborts. Required for caller-driven
|
||||
* cancellation (ESC) when the underlying transport does not surface signal
|
||||
@@ -157,7 +167,7 @@ export async function* iterateWithIdleTimeout<T>(
|
||||
options: IdleTimeoutIteratorOptions,
|
||||
): AsyncGenerator<T> {
|
||||
const firstItemTimeoutMs = options.firstItemTimeoutMs ?? options.idleTimeoutMs;
|
||||
const firstItemDeadlineMs =
|
||||
let firstItemDeadlineMs =
|
||||
firstItemTimeoutMs !== undefined && firstItemTimeoutMs > 0 ? Date.now() + firstItemTimeoutMs : undefined;
|
||||
const abortSignal = options.abortSignal;
|
||||
const iterator = iterable[Symbol.asyncIterator]();
|
||||
@@ -197,6 +207,28 @@ export async function* iterateWithIdleTimeout<T>(
|
||||
};
|
||||
let lastProgressAt = Date.now();
|
||||
|
||||
const hasPendingLocalWork = (): boolean => {
|
||||
if (!options.hasPendingLocalWork) return false;
|
||||
try {
|
||||
return options.hasPendingLocalWork();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// Local work means the current gap is attributable to the consumer side,
|
||||
// not the provider: slide the active deadline a full budget past now
|
||||
// instead of aborting. Once the work completes the watchdog resumes from
|
||||
// the last extension, so a provider that stalls afterwards is still caught.
|
||||
const extendDeadlineForLocalWork = (): void => {
|
||||
if (awaitingFirstItem) {
|
||||
if (firstItemDeadlineMs !== undefined && firstItemTimeoutMs !== undefined) {
|
||||
firstItemDeadlineMs = Date.now() + firstItemTimeoutMs;
|
||||
}
|
||||
} else {
|
||||
lastProgressAt = Date.now();
|
||||
}
|
||||
};
|
||||
|
||||
const noTimeoutEnforced =
|
||||
(firstItemTimeoutMs === undefined || firstItemTimeoutMs <= 0) &&
|
||||
(options.idleTimeoutMs === undefined || options.idleTimeoutMs <= 0);
|
||||
@@ -271,6 +303,12 @@ export async function* iterateWithIdleTimeout<T>(
|
||||
timer = setTimeout(onTimerFire, Math.max(0, deadlineMs - Date.now()));
|
||||
};
|
||||
|
||||
// The in-flight iterator.next() promise, persisted across loop iterations:
|
||||
// a deadline extension for pending local work loops without consuming it,
|
||||
// and issuing a second next() while one is outstanding would drop an item.
|
||||
let pendingNext:
|
||||
| Promise<{ kind: "next"; result: IteratorResult<T> } | { kind: "error"; error: unknown }>
|
||||
| undefined;
|
||||
try {
|
||||
let raceCount = 0;
|
||||
while (true) {
|
||||
@@ -291,21 +329,29 @@ export async function* iterateWithIdleTimeout<T>(
|
||||
if (firstItemDeadlineMs !== undefined) {
|
||||
activeTimeoutMs = firstItemDeadlineMs - Date.now();
|
||||
if (activeTimeoutMs <= 0) {
|
||||
options.onFirstItemTimeout?.();
|
||||
closeIterator();
|
||||
throw new AIError.StreamTimeoutError(options.firstItemErrorMessage ?? options.errorMessage);
|
||||
if (!hasPendingLocalWork()) {
|
||||
options.onFirstItemTimeout?.();
|
||||
closeIterator();
|
||||
throw new AIError.StreamTimeoutError(options.firstItemErrorMessage ?? options.errorMessage);
|
||||
}
|
||||
extendDeadlineForLocalWork();
|
||||
activeTimeoutMs = firstItemDeadlineMs! - Date.now();
|
||||
}
|
||||
}
|
||||
} else if (options.idleTimeoutMs !== undefined && options.idleTimeoutMs > 0) {
|
||||
activeTimeoutMs = options.idleTimeoutMs - (Date.now() - lastProgressAt);
|
||||
if (activeTimeoutMs <= 0) {
|
||||
options.onIdle?.();
|
||||
closeIterator();
|
||||
throw new AIError.StreamTimeoutError(options.errorMessage);
|
||||
if (!hasPendingLocalWork()) {
|
||||
options.onIdle?.();
|
||||
closeIterator();
|
||||
throw new AIError.StreamTimeoutError(options.errorMessage);
|
||||
}
|
||||
extendDeadlineForLocalWork();
|
||||
activeTimeoutMs = options.idleTimeoutMs;
|
||||
}
|
||||
}
|
||||
|
||||
const nextResultPromise = withRacy(iterator.next());
|
||||
pendingNext ??= withRacy(iterator.next());
|
||||
|
||||
const racers: Array<
|
||||
Promise<
|
||||
@@ -314,7 +360,7 @@ export async function* iterateWithIdleTimeout<T>(
|
||||
| { kind: "timeout" }
|
||||
| { kind: "abort" }
|
||||
>
|
||||
> = [nextResultPromise];
|
||||
> = [pendingNext];
|
||||
|
||||
const enforceTimeout = !noTimeoutEnforced && activeTimeoutMs !== undefined && activeTimeoutMs > 0;
|
||||
if (enforceTimeout) {
|
||||
@@ -333,11 +379,21 @@ export async function* iterateWithIdleTimeout<T>(
|
||||
let continuing = false;
|
||||
try {
|
||||
const outcome = await Promise.race(racers);
|
||||
if (outcome.kind === "next" || outcome.kind === "error") {
|
||||
pendingNext = undefined;
|
||||
}
|
||||
if (outcome.kind === "abort") {
|
||||
closeIterator();
|
||||
throw abortReason(abortSignal!);
|
||||
}
|
||||
if (outcome.kind === "timeout") {
|
||||
if (hasPendingLocalWork()) {
|
||||
// A local tool is still running; the provider cannot make
|
||||
// progress until we hand its result back. Keep waiting.
|
||||
extendDeadlineForLocalWork();
|
||||
continuing = true;
|
||||
continue;
|
||||
}
|
||||
if (!awaitingFirstItem) {
|
||||
options.onIdle?.();
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
||||
import { streamAnthropic } from "../src/providers/anthropic";
|
||||
import type { AnthropicMessagesClientLike } from "../src/providers/anthropic-client";
|
||||
import type { Context, Model } from "../src/types";
|
||||
import { waitForDelayOrAbort } from "./helpers";
|
||||
|
||||
const model: Model<"anthropic-messages"> = buildModel({
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 8_192,
|
||||
});
|
||||
|
||||
const context: Context = {
|
||||
messages: [{ role: "user", content: "write a file", timestamp: Date.now() }],
|
||||
};
|
||||
|
||||
type MockAnthropicEvent = Record<string, unknown>;
|
||||
|
||||
/** `{ waitMs, event }` script step; `waitMs` elapses (fake clock) before the event is yielded. */
|
||||
type ScriptStep = { waitMs: number; event: MockAnthropicEvent | "hang-with-pings" };
|
||||
|
||||
const writeToolCallOpening: MockAnthropicEvent[] = [
|
||||
{
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: "msg_ping_keepalive",
|
||||
usage: { input_tokens: 10, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "tool_use", id: "toolu_ping_keepalive", name: "write", input: {} },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "input_json_delta", partial_json: '{"path":"notes.md",' },
|
||||
},
|
||||
];
|
||||
|
||||
const writeToolCallClosing: MockAnthropicEvent[] = [
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "input_json_delta", partial_json: '"content":"hello world"}' },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "tool_use" },
|
||||
usage: { input_tokens: 10, output_tokens: 6, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
];
|
||||
|
||||
function createScriptedClient(
|
||||
script: ScriptStep[],
|
||||
counters: { pings: number },
|
||||
onIteratorStart: () => void,
|
||||
): AnthropicMessagesClientLike {
|
||||
const create = ((_body: unknown, requestOptions?: { signal?: AbortSignal }) => {
|
||||
const signal = requestOptions?.signal;
|
||||
const response = new Response(null, { status: 200, headers: { "request-id": "req_ping_keepalive" } });
|
||||
const stream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
onIteratorStart();
|
||||
for (const step of script) {
|
||||
if (step.event === "hang-with-pings") {
|
||||
// Wedged upstream: no semantic events ever again, but the edge
|
||||
// keeps the SSE connection alive with keepalive pings.
|
||||
while (true) {
|
||||
await waitForDelayOrAbort(step.waitMs, signal);
|
||||
counters.pings += 1;
|
||||
yield { type: "ping" };
|
||||
}
|
||||
}
|
||||
if (step.waitMs > 0) {
|
||||
await waitForDelayOrAbort(step.waitMs, signal);
|
||||
}
|
||||
if (step.event.type === "ping") counters.pings += 1;
|
||||
yield step.event;
|
||||
}
|
||||
},
|
||||
};
|
||||
return {
|
||||
async withResponse() {
|
||||
return { data: stream, response, request_id: "req_ping_keepalive" };
|
||||
},
|
||||
} as never;
|
||||
}) as unknown as AnthropicMessagesClientLike["messages"]["create"];
|
||||
return { messages: { create } } as AnthropicMessagesClientLike;
|
||||
}
|
||||
|
||||
async function drainMicrotasks(count: number): Promise<void> {
|
||||
for (let i = 0; i < count; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
async function drainMicrotasksUntil(predicate: () => boolean, errorMessage: string): Promise<void> {
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
if (predicate()) return;
|
||||
await Promise.resolve();
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("anthropic ping keepalive idle cap", () => {
|
||||
it("times out a stalled tool-call stream instead of letting pings extend it forever", async () => {
|
||||
vi.useFakeTimers();
|
||||
const counters = { pings: 0 };
|
||||
let iteratorStarted = false;
|
||||
const script: ScriptStep[] = [
|
||||
...writeToolCallOpening.map(event => ({ waitMs: 0, event })),
|
||||
{ waitMs: 500, event: "hang-with-pings" as const },
|
||||
];
|
||||
const client = createScriptedClient(script, counters, () => {
|
||||
iteratorStarted = true;
|
||||
});
|
||||
const providerRetryWait = vi.fn(async () => {});
|
||||
|
||||
let settled = false;
|
||||
const resultPromise = streamAnthropic(model, context, {
|
||||
client,
|
||||
streamFirstEventTimeoutMs: 1_000,
|
||||
streamIdleTimeoutMs: 1_000,
|
||||
providerRetryWait,
|
||||
})
|
||||
.result()
|
||||
.then(message => {
|
||||
settled = true;
|
||||
return message;
|
||||
});
|
||||
|
||||
await drainMicrotasksUntil(() => iteratorStarted, "Anthropic mock stream never started");
|
||||
await drainMicrotasks(30);
|
||||
|
||||
// Pings arrive every 500 fake-ms while generation is wedged. Drive far
|
||||
// past the bounded keepalive window (3x idle = 3_000ms) plus one idle
|
||||
// budget; without the cap the idle deadline is reset by every ping and
|
||||
// this loop ends with the result still pending (issue #4900's hang).
|
||||
let stepsRun = 0;
|
||||
for (let step = 0; step < 40 && !settled; step++) {
|
||||
vi.advanceTimersByTime(500);
|
||||
await drainMicrotasks(30);
|
||||
stepsRun = step + 1;
|
||||
}
|
||||
|
||||
expect(settled).toBe(true);
|
||||
// Cap (3_000ms) + idle budget (1_000ms) = fires at 3_500-4_000 fake ms.
|
||||
expect(stepsRun).toBeLessThanOrEqual(9);
|
||||
// Keepalives within the window were honored before the watchdog fired.
|
||||
expect(counters.pings).toBeGreaterThanOrEqual(5);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(result.errorMessage).toBe("Anthropic stream stalled while waiting for the next event");
|
||||
// Mid-stream idle stalls are terminal for the provider loop (session-level
|
||||
// auto-retry owns recovery); the provider must not silently re-request.
|
||||
expect(providerRetryWait).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps a slow-but-alive stream open across ping-bridged gaps within the cap", async () => {
|
||||
vi.useFakeTimers();
|
||||
const counters = { pings: 0 };
|
||||
let iteratorStarted = false;
|
||||
// Silent generation gap of 1_800ms (> 1_000ms idle budget) bridged by
|
||||
// pings at t=600 and t=1200, then semantic progress resumes and the
|
||||
// tool call completes. Pings within the cap must count as liveness.
|
||||
const script: ScriptStep[] = [
|
||||
...writeToolCallOpening.map(event => ({ waitMs: 0, event })),
|
||||
{ waitMs: 600, event: { type: "ping" } },
|
||||
{ waitMs: 600, event: { type: "ping" } },
|
||||
{ waitMs: 600, event: writeToolCallClosing[0]! },
|
||||
...writeToolCallClosing.slice(1).map(event => ({ waitMs: 0, event })),
|
||||
];
|
||||
const client = createScriptedClient(script, counters, () => {
|
||||
iteratorStarted = true;
|
||||
});
|
||||
const providerRetryWait = vi.fn(async () => {});
|
||||
|
||||
let settled = false;
|
||||
const resultPromise = streamAnthropic(model, context, {
|
||||
client,
|
||||
streamFirstEventTimeoutMs: 1_000,
|
||||
streamIdleTimeoutMs: 1_000,
|
||||
providerRetryWait,
|
||||
})
|
||||
.result()
|
||||
.then(message => {
|
||||
settled = true;
|
||||
return message;
|
||||
});
|
||||
|
||||
await drainMicrotasksUntil(() => iteratorStarted, "Anthropic mock stream never started");
|
||||
await drainMicrotasks(30);
|
||||
|
||||
for (let step = 0; step < 30 && !settled; step++) {
|
||||
vi.advanceTimersByTime(200);
|
||||
await drainMicrotasks(30);
|
||||
}
|
||||
|
||||
expect(settled).toBe(true);
|
||||
expect(counters.pings).toBe(2);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result.errorMessage).toBeUndefined();
|
||||
expect(result.stopReason).toBe("toolUse");
|
||||
expect(providerRetryWait).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(JSON.stringify(result.content))).toEqual([
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "toolu_ping_keepalive",
|
||||
name: "write",
|
||||
arguments: { path: "notes.md", content: "hello world" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
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 { resolveAuthBrokerConfig } from "@oh-my-pi/pi-ai/auth-broker";
|
||||
import { removeWithRetries } from "../../utils/src/temp";
|
||||
import { withEnv } from "./helpers";
|
||||
|
||||
const SUPPRESS_AUTH_BROKER_ENV = {
|
||||
OMP_AUTH_BROKER_URL: undefined,
|
||||
OMP_AUTH_BROKER_TOKEN: undefined,
|
||||
} as const;
|
||||
|
||||
describe("resolveAuthBrokerConfig config discovery", () => {
|
||||
let agentDir = "";
|
||||
|
||||
beforeEach(async () => {
|
||||
agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-ai-auth-broker-config-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (agentDir) {
|
||||
await removeWithRetries(agentDir);
|
||||
agentDir = "";
|
||||
}
|
||||
});
|
||||
|
||||
test("resolves broker URL and token from config.yaml when config.yml is absent", async () => {
|
||||
await Bun.write(
|
||||
path.join(agentDir, "config.yaml"),
|
||||
"auth.broker.url: https://yaml-broker.example/v1\nauth.broker.token: yaml-token\n",
|
||||
);
|
||||
|
||||
await withEnv(SUPPRESS_AUTH_BROKER_ENV, async () => {
|
||||
await expect(resolveAuthBrokerConfig({ agentDir })).resolves.toEqual({
|
||||
url: "https://yaml-broker.example/v1",
|
||||
token: "yaml-token",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("prefers config.yml over config.yaml when both exist", async () => {
|
||||
await Bun.write(
|
||||
path.join(agentDir, "config.yaml"),
|
||||
"auth.broker.url: https://yaml-broker.example/v1\nauth.broker.token: yaml-token\n",
|
||||
);
|
||||
await Bun.write(
|
||||
path.join(agentDir, "config.yml"),
|
||||
"auth.broker.url: https://yml-broker.example/v1\nauth.broker.token: yml-token\n",
|
||||
);
|
||||
|
||||
await withEnv(SUPPRESS_AUTH_BROKER_ENV, async () => {
|
||||
await expect(resolveAuthBrokerConfig({ agentDir })).resolves.toEqual({
|
||||
url: "https://yml-broker.example/v1",
|
||||
token: "yml-token",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,25 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import {
|
||||
type BlockState,
|
||||
buildCursorHistoryForTest,
|
||||
buildCursorSystemPromptJsons,
|
||||
emptyGrepPatternRejection,
|
||||
handleServerMessage,
|
||||
resolveExecHandler,
|
||||
streamCursor,
|
||||
type ToolCallState,
|
||||
} from "@oh-my-pi/pi-ai/providers/cursor";
|
||||
import type { Context, Model } from "@oh-my-pi/pi-ai/types";
|
||||
import { streamCursor as lazyStreamCursor, setCursorProviderModule } from "@oh-my-pi/pi-ai/providers/register-builtins";
|
||||
import type { AssistantMessage, Context, CursorExecHandlers, Model, ToolResultMessage } from "@oh-my-pi/pi-ai/types";
|
||||
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
|
||||
import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
||||
import type { AgentRunRequest } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
|
||||
import {
|
||||
type AgentRunRequest,
|
||||
AgentServerMessageSchema,
|
||||
ExecServerMessageSchema,
|
||||
ReadArgsSchema,
|
||||
} from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
|
||||
|
||||
const cursorModel: Model<"cursor-agent"> = buildModel({
|
||||
id: "cursor-composer-2.5",
|
||||
@@ -361,3 +372,188 @@ describe("Cursor grepArgs empty-pattern guard (issue #4574)", () => {
|
||||
expect(emptyGrepPatternRejection("\t\n", "src/**/*.ts")).toContain('"src/**/*.ts"');
|
||||
});
|
||||
});
|
||||
|
||||
function cursorAssistantMessage(): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: "cursor-agent",
|
||||
provider: "cursor",
|
||||
model: "cursor-composer-2.5",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function newBlockState(): BlockState {
|
||||
let textBlock: BlockState["currentTextBlock"] = null;
|
||||
let thinkingBlock: BlockState["currentThinkingBlock"] = null;
|
||||
let toolCall: ToolCallState | null = null;
|
||||
return {
|
||||
get currentTextBlock() {
|
||||
return textBlock;
|
||||
},
|
||||
get currentThinkingBlock() {
|
||||
return thinkingBlock;
|
||||
},
|
||||
get currentToolCall() {
|
||||
return toolCall;
|
||||
},
|
||||
firstTokenTime: undefined,
|
||||
setTextBlock: b => {
|
||||
textBlock = b;
|
||||
},
|
||||
setThinkingBlock: b => {
|
||||
thinkingBlock = b;
|
||||
},
|
||||
setToolCall: t => {
|
||||
toolCall = t;
|
||||
},
|
||||
setFirstTokenTime: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("Cursor exec local-work tracking (issue #4593)", () => {
|
||||
it("marks the stream busy for the duration of a local exec handler", async () => {
|
||||
const output = cursorAssistantMessage();
|
||||
const stream = new AssistantMessageEventStream();
|
||||
const state = newBlockState();
|
||||
const written: unknown[] = [];
|
||||
const h2Request = {
|
||||
write: (chunk: unknown) => {
|
||||
written.push(chunk);
|
||||
return true;
|
||||
},
|
||||
} as unknown as Parameters<typeof handleServerMessage>[5];
|
||||
const handlerGate = Promise.withResolvers<void>();
|
||||
const execHandlers: CursorExecHandlers = {
|
||||
async read(args) {
|
||||
await handlerGate.promise;
|
||||
return {
|
||||
role: "toolResult",
|
||||
toolCallId: args.toolCallId,
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "file contents" }],
|
||||
isError: false,
|
||||
timestamp: 1,
|
||||
} satisfies ToolResultMessage;
|
||||
},
|
||||
};
|
||||
const serverMsg = create(AgentServerMessageSchema, {
|
||||
message: {
|
||||
case: "execServerMessage",
|
||||
value: create(ExecServerMessageSchema, {
|
||||
id: 1,
|
||||
execId: "exec-1",
|
||||
message: {
|
||||
case: "readArgs",
|
||||
value: create(ReadArgsSchema, { path: "/tmp/slow-file", toolCallId: "call-read-1" }),
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(stream.hasPendingLocalWork).toBe(false);
|
||||
const dispatch = handleServerMessage(
|
||||
serverMsg,
|
||||
output,
|
||||
stream,
|
||||
state,
|
||||
new Map(),
|
||||
h2Request,
|
||||
execHandlers,
|
||||
undefined,
|
||||
{ sawTokenDelta: false },
|
||||
[],
|
||||
);
|
||||
|
||||
// The exec round-trip is in flight: the stream must advertise local
|
||||
// work so the lazy idle watchdog defers instead of aborting.
|
||||
expect(stream.hasPendingLocalWork).toBe(true);
|
||||
|
||||
handlerGate.resolve();
|
||||
await dispatch;
|
||||
|
||||
expect(stream.hasPendingLocalWork).toBe(false);
|
||||
// The read result went back out on the exec channel.
|
||||
expect(written.length).toBe(1);
|
||||
});
|
||||
|
||||
it("survives a local exec tool outliving the lazy idle budget end to end", async () => {
|
||||
const workDone = Promise.withResolvers<void>();
|
||||
// The tracked work completes only once the lazy watchdog has consulted
|
||||
// the stream's local-work state at two expired deadlines, proving the
|
||||
// idle budget was truly exceeded while the exec tool ran.
|
||||
class ProbedStream extends AssistantMessageEventStream {
|
||||
probeCalls = 0;
|
||||
override get hasPendingLocalWork(): boolean {
|
||||
this.probeCalls++;
|
||||
if (this.probeCalls >= 2) workDone.resolve();
|
||||
return super.hasPendingLocalWork;
|
||||
}
|
||||
}
|
||||
const source = new ProbedStream();
|
||||
let providerSignal: AbortSignal | undefined;
|
||||
setCursorProviderModule({
|
||||
streamCursor: (_model, _context, options) => {
|
||||
providerSignal = options.signal;
|
||||
void (async () => {
|
||||
const partial = cursorAssistantMessage();
|
||||
source.push({ type: "start", partial });
|
||||
source.push({ type: "text_delta", contentIndex: 0, delta: "spawning local tool", partial });
|
||||
await source.trackLocalWork(workDone.promise);
|
||||
const message = cursorAssistantMessage();
|
||||
source.push({ type: "done", reason: "stop", message });
|
||||
})();
|
||||
return source;
|
||||
},
|
||||
});
|
||||
|
||||
const stream = lazyStreamCursor(cursorModel, { messages: [] }, { apiKey: "test", streamIdleTimeoutMs: 5 });
|
||||
const result = await stream.result();
|
||||
|
||||
expect(providerSignal?.aborted).toBe(false);
|
||||
expect(source.probeCalls).toBeGreaterThanOrEqual(2);
|
||||
expect(result.stopReason).toBe("stop");
|
||||
});
|
||||
|
||||
it("still aborts a silent cursor stream with no local work in flight", async () => {
|
||||
const partial = cursorAssistantMessage();
|
||||
let providerSignal: AbortSignal | undefined;
|
||||
const source = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield { type: "start", partial } as const;
|
||||
yield { type: "text_delta", contentIndex: 0, delta: "hello", partial } as const;
|
||||
const stalled = Promise.withResolvers<never>();
|
||||
if (providerSignal?.aborted) {
|
||||
stalled.reject(new Error("Request was aborted"));
|
||||
}
|
||||
providerSignal?.addEventListener("abort", () => stalled.reject(new Error("Request was aborted")), {
|
||||
once: true,
|
||||
});
|
||||
await stalled.promise;
|
||||
},
|
||||
} as unknown as AssistantMessageEventStream;
|
||||
setCursorProviderModule({
|
||||
streamCursor: (_model, _context, options) => {
|
||||
providerSignal = options.signal;
|
||||
return source;
|
||||
},
|
||||
});
|
||||
|
||||
const stream = lazyStreamCursor(cursorModel, { messages: [] }, { apiKey: "test", streamIdleTimeoutMs: 10 });
|
||||
const result = await stream.result();
|
||||
|
||||
expect(providerSignal?.aborted).toBe(true);
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(result.errorMessage).toBe("Provider stream stalled while waiting for the next event");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
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 { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
|
||||
import { iterateWithIdleTimeout } from "@oh-my-pi/pi-ai/utils/idle-iterator";
|
||||
import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
||||
|
||||
// Issue #4593: the generic lazy stream watchdog treats "no AssistantMessageEvent"
|
||||
// as "provider stalled". During a Cursor exec-channel round-trip the server is
|
||||
// waiting on OUR local tool result and legitimately sends nothing, so a local
|
||||
// tool outliving the idle budget aborted a healthy stream with "Provider stream
|
||||
// stalled while waiting for the next event". Provider streams now advertise
|
||||
// pending local work and the watchdog slides its deadline instead of aborting.
|
||||
//
|
||||
// These tests exercise the real watchdog timer against the platform clock (that
|
||||
// timer IS the unit under test), but never guess durations: the simulated local
|
||||
// work completes only once the watchdog has demonstrably reached an expired
|
||||
// deadline and consulted the local-work probe, so the tests stay causal on a
|
||||
// loaded machine. Budgets are a few milliseconds.
|
||||
|
||||
function createModel(): Model<"bedrock-converse-stream"> {
|
||||
return buildModel({
|
||||
id: "mock-bedrock",
|
||||
name: "Mock Bedrock",
|
||||
api: "bedrock-converse-stream",
|
||||
provider: "amazon-bedrock",
|
||||
baseUrl: "https://example.invalid",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8192,
|
||||
maxTokens: 2048,
|
||||
});
|
||||
}
|
||||
|
||||
function createAssistantMessage(): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
api: "bedrock-converse-stream",
|
||||
provider: "amazon-bedrock",
|
||||
model: "mock-bedrock",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
const baseContext: Context = { messages: [] };
|
||||
|
||||
describe("idle watchdog local-work deferral (issue #4593)", () => {
|
||||
it("slides the idle deadline while consumer-side local work is pending", async () => {
|
||||
const workDone = Promise.withResolvers<void>();
|
||||
let probeCalls = 0;
|
||||
let busy = true;
|
||||
async function* source() {
|
||||
yield "first";
|
||||
// The "local tool": finishes only after the watchdog has hit an
|
||||
// expired deadline twice and deferred both times.
|
||||
await workDone.promise;
|
||||
busy = false;
|
||||
yield "second";
|
||||
}
|
||||
let idleFired = false;
|
||||
const items: string[] = [];
|
||||
for await (const item of iterateWithIdleTimeout(source(), {
|
||||
idleTimeoutMs: 5,
|
||||
errorMessage: "stalled",
|
||||
onIdle: () => {
|
||||
idleFired = true;
|
||||
},
|
||||
hasPendingLocalWork: () => {
|
||||
probeCalls++;
|
||||
if (probeCalls >= 2) workDone.resolve();
|
||||
return busy;
|
||||
},
|
||||
})) {
|
||||
items.push(item);
|
||||
}
|
||||
expect(items).toEqual(["first", "second"]);
|
||||
expect(probeCalls).toBeGreaterThanOrEqual(2);
|
||||
expect(idleFired).toBe(false);
|
||||
});
|
||||
|
||||
it("still aborts a silent stream once local work has finished", async () => {
|
||||
const workDone = Promise.withResolvers<void>();
|
||||
let busy = true;
|
||||
async function* source() {
|
||||
yield "first";
|
||||
await workDone.promise;
|
||||
busy = false;
|
||||
// The provider genuinely stalls after the local work completed.
|
||||
await new Promise<never>(() => {});
|
||||
yield "never";
|
||||
}
|
||||
const items: string[] = [];
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
for await (const item of iterateWithIdleTimeout(source(), {
|
||||
idleTimeoutMs: 5,
|
||||
errorMessage: "stalled",
|
||||
hasPendingLocalWork: () => {
|
||||
workDone.resolve();
|
||||
return busy;
|
||||
},
|
||||
})) {
|
||||
items.push(item);
|
||||
}
|
||||
} catch (err) {
|
||||
error = err as Error;
|
||||
}
|
||||
expect(items).toEqual(["first"]);
|
||||
expect(error?.message).toBe("stalled");
|
||||
});
|
||||
|
||||
it("slides the first-event deadline while local work is pending", async () => {
|
||||
const workDone = Promise.withResolvers<void>();
|
||||
let probeCalls = 0;
|
||||
let busy = true;
|
||||
async function* source() {
|
||||
// Local bridge work before the model has produced any event.
|
||||
await workDone.promise;
|
||||
yield "first";
|
||||
}
|
||||
const items: string[] = [];
|
||||
for await (const item of iterateWithIdleTimeout(source(), {
|
||||
idleTimeoutMs: 5,
|
||||
firstItemTimeoutMs: 5,
|
||||
errorMessage: "stalled",
|
||||
firstItemErrorMessage: "first event timed out",
|
||||
hasPendingLocalWork: () => {
|
||||
probeCalls++;
|
||||
if (probeCalls >= 2) workDone.resolve();
|
||||
return busy;
|
||||
},
|
||||
})) {
|
||||
items.push(item);
|
||||
busy = false;
|
||||
}
|
||||
expect(items).toEqual(["first"]);
|
||||
expect(probeCalls).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("does not abort a lazy provider stream while tracked local work outlives the idle budget", async () => {
|
||||
const workDone = Promise.withResolvers<void>();
|
||||
// Counts how often the lazy wrapper's watchdog consults the stream's
|
||||
// local-work state at an expired deadline; the tracked work completes
|
||||
// only after two deferrals, proving the budget was truly exceeded.
|
||||
class ProbedStream extends AssistantMessageEventStream {
|
||||
probeCalls = 0;
|
||||
override get hasPendingLocalWork(): boolean {
|
||||
this.probeCalls++;
|
||||
if (this.probeCalls >= 2) workDone.resolve();
|
||||
return super.hasPendingLocalWork;
|
||||
}
|
||||
}
|
||||
const source = new ProbedStream();
|
||||
let providerSignal: AbortSignal | undefined;
|
||||
setBedrockProviderModule({
|
||||
streamBedrock: (_model, _context, options) => {
|
||||
providerSignal = options.signal;
|
||||
void (async () => {
|
||||
const partial = createAssistantMessage();
|
||||
source.push({ type: "start", partial });
|
||||
source.push({ type: "text_delta", contentIndex: 0, delta: "running a local tool", partial });
|
||||
// Server-driven local tool run: no events flow while the
|
||||
// tracked work is pending.
|
||||
await source.trackLocalWork(workDone.promise);
|
||||
source.push({ type: "done", reason: "stop", message: createAssistantMessage() });
|
||||
})();
|
||||
return source;
|
||||
},
|
||||
});
|
||||
|
||||
const stream = streamBedrock(createModel(), baseContext, { streamIdleTimeoutMs: 5 });
|
||||
const result = await stream.result();
|
||||
|
||||
expect(providerSignal?.aborted).toBe(false);
|
||||
expect(source.probeCalls).toBeGreaterThanOrEqual(2);
|
||||
expect(result.stopReason).toBe("stop");
|
||||
expect(result.errorMessage).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -442,6 +442,129 @@ describe("openai-codex streaming", () => {
|
||||
expect(capturedText).toEqual({ verbosity: "low" });
|
||||
});
|
||||
|
||||
it("preserves streamed reasoning when the done item has no summary text", async () => {
|
||||
const token = createCodexTestToken();
|
||||
const model = { ...createCodexTestModel("https://chatgpt.com/backend-api"), preferWebsockets: false };
|
||||
const events = [
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 0,
|
||||
item: { type: "reasoning", id: "rs_1", summary: [] },
|
||||
},
|
||||
{
|
||||
type: "response.reasoning_summary_part.added",
|
||||
output_index: 0,
|
||||
item_id: "rs_1",
|
||||
summary_index: 0,
|
||||
part: { type: "summary_text", text: "" },
|
||||
},
|
||||
{
|
||||
type: "response.reasoning_summary_text.delta",
|
||||
output_index: 0,
|
||||
item_id: "rs_1",
|
||||
summary_index: 0,
|
||||
delta: "streamed thinking",
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 0,
|
||||
item: { type: "reasoning", id: "rs_1", summary: [] },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 1,
|
||||
item: { type: "message", id: "msg_1", role: "assistant", status: "in_progress", content: [] },
|
||||
},
|
||||
{
|
||||
type: "response.content_part.added",
|
||||
output_index: 1,
|
||||
item_id: "msg_1",
|
||||
part: { type: "output_text", text: "" },
|
||||
},
|
||||
{ type: "response.output_text.delta", output_index: 1, item_id: "msg_1", delta: "done" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 1,
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_1",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "done" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
status: "completed",
|
||||
usage: {
|
||||
input_tokens: 5,
|
||||
output_tokens: 3,
|
||||
total_tokens: 8,
|
||||
input_tokens_details: { cached_tokens: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
const sse = `${events.map(event => `data: ${JSON.stringify(event)}`).join("\n\n")}\n\n`;
|
||||
const fetchMock: FetchImpl = async () =>
|
||||
new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } });
|
||||
|
||||
const result = await streamOpenAICodexResponses(model, createCodexTestContext(), {
|
||||
apiKey: token,
|
||||
fetch: fetchMock,
|
||||
}).result();
|
||||
|
||||
expect(result.content.find(block => block.type === "thinking")?.thinking).toBe("streamed thinking");
|
||||
});
|
||||
|
||||
it("streams raw reasoning text deltas into the final thinking block", async () => {
|
||||
const token = createCodexTestToken();
|
||||
const model = { ...createCodexTestModel("https://chatgpt.com/backend-api"), preferWebsockets: false };
|
||||
const events = [
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 0,
|
||||
item: { type: "reasoning", id: "rs_raw", summary: [] },
|
||||
},
|
||||
{
|
||||
type: "response.reasoning_text.delta",
|
||||
output_index: 0,
|
||||
item_id: "rs_raw",
|
||||
delta: "raw streamed thinking",
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 0,
|
||||
item: { type: "reasoning", id: "rs_raw", summary: [] },
|
||||
},
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_raw",
|
||||
status: "completed",
|
||||
usage: {
|
||||
input_tokens: 5,
|
||||
output_tokens: 3,
|
||||
total_tokens: 8,
|
||||
input_tokens_details: { cached_tokens: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
const sse = `${events.map(event => `data: ${JSON.stringify(event)}`).join("\n\n")}\n\n`;
|
||||
const fetchMock: FetchImpl = async () =>
|
||||
new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } });
|
||||
|
||||
const result = await streamOpenAICodexResponses(model, createCodexTestContext(), {
|
||||
apiKey: token,
|
||||
fetch: fetchMock,
|
||||
}).result();
|
||||
|
||||
expect(result.content.find(block => block.type === "thinking")?.thinking).toBe("raw streamed thinking");
|
||||
});
|
||||
|
||||
it("maps end_turn=false on the terminal event to a pause_turn stop", async () => {
|
||||
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
||||
setAgentDir(tempDir.path());
|
||||
|
||||
@@ -314,6 +314,36 @@ describe("openai-codex reasoning effort validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("openai-codex reasoning effort wire mapping", () => {
|
||||
it("shifts gpt-5.6 user efforts one wire tier up via the baked effort map", async () => {
|
||||
const model = createCodexModel("gpt-5.6-sol");
|
||||
const shifted = [
|
||||
["minimal", "low"],
|
||||
["low", "medium"],
|
||||
["medium", "high"],
|
||||
["high", "xhigh"],
|
||||
["xhigh", "max"],
|
||||
] as const;
|
||||
|
||||
for (const [requested, wire] of shifted) {
|
||||
const transformed = await transformRequestBody({ model: model.id }, model, {
|
||||
reasoningEffort: requested,
|
||||
});
|
||||
expect(transformed.reasoning?.effort).toBe(wire);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps pre-5.6 efforts unshifted and passes none through unmapped", async () => {
|
||||
const gpt55 = createCodexModel("gpt-5.5");
|
||||
const unshifted = await transformRequestBody({ model: gpt55.id }, gpt55, { reasoningEffort: "xhigh" });
|
||||
expect(unshifted.reasoning?.effort).toBe("xhigh");
|
||||
|
||||
const gpt56 = createCodexModel("gpt-5.6-sol");
|
||||
const none = await transformRequestBody({ model: gpt56.id }, gpt56, { reasoningEffort: "none" });
|
||||
expect(none.reasoning?.effort).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("openai-codex error parsing", () => {
|
||||
it("produces friendly usage-limit messages and rate limits", async () => {
|
||||
const resetAt = Math.floor(Date.now() / 1000) + 600;
|
||||
|
||||
@@ -311,6 +311,49 @@ describe("processResponsesStream: lost output_item.added recovery", () => {
|
||||
expect(second.thinkingSignature).toBeDefined();
|
||||
});
|
||||
|
||||
test("preserves streamed reasoning when the done item has no summary text", async () => {
|
||||
const output = makeOutput();
|
||||
const stream = { push: () => {}, end: () => {} } as never;
|
||||
|
||||
await processResponsesStream(
|
||||
makeStream([
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 0,
|
||||
item: { type: "reasoning", id: "rs_1", summary: [] },
|
||||
},
|
||||
{
|
||||
type: "response.reasoning_summary_part.added",
|
||||
output_index: 0,
|
||||
item_id: "rs_1",
|
||||
summary_index: 0,
|
||||
part: { type: "summary_text", text: "" },
|
||||
},
|
||||
{
|
||||
type: "response.reasoning_summary_text.delta",
|
||||
output_index: 0,
|
||||
item_id: "rs_1",
|
||||
summary_index: 0,
|
||||
delta: "streamed thinking",
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 0,
|
||||
item: { type: "reasoning", id: "rs_1", summary: [] },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_reasoning", status: "completed" } },
|
||||
]),
|
||||
output,
|
||||
stream,
|
||||
makeModel(),
|
||||
);
|
||||
|
||||
const block = output.content[0];
|
||||
if (block?.type !== "thinking") throw new Error("expected a thinking block");
|
||||
expect(block.thinking).toBe("streamed thinking");
|
||||
expect(block.thinkingSignature).toBeDefined();
|
||||
});
|
||||
|
||||
test("treats content_filter incomplete responses as errors, not length", async () => {
|
||||
const output = makeOutput();
|
||||
const stream = { push: () => {}, end: () => {} } as never;
|
||||
|
||||
@@ -81,6 +81,7 @@ describe("provider registry auth surface", () => {
|
||||
"google-antigravity",
|
||||
"google-gemini-cli",
|
||||
"openai-codex",
|
||||
"xai-oauth",
|
||||
].sort(),
|
||||
);
|
||||
expect(PASTE_CODE_LOGIN_PROVIDERS.has("zenmux")).toBe(false);
|
||||
|
||||
@@ -2,6 +2,30 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [16.3.14] - 2026-07-09
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for GPT-5.6 (Luna, Sol, Terra) model variants
|
||||
- Enabled expanded five-tier reasoning effort scale (minimal to xhigh) for GPT-5.6 models
|
||||
- Added GPT-5.6 (Terra/Luna/Sol) support for the new `max` reasoning tier: on wire-effort APIs (OpenAI Responses, Codex, Azure, openai-compat/OpenRouter models that advertise reasoning) user efforts shift up one notch — `xhigh` sends `max`, `high` sends `xhigh` — mirroring the Claude Fable/Opus 4.7+ five-tier mapping, and the exposed ladder becomes `minimal..xhigh` with `minimal` reaching the native `low` tier. Devin's per-tier GPT-5.6 sibling rows now collapse into `gpt-5-6-{luna,sol,terra}` logical models with the same shifted routing (`xhigh` → `-max`), plus `-fast` families that keep the direct `low..xhigh` `-priority` scale since Devin serves no `-max-priority` tier.
|
||||
|
||||
## [16.3.13] - 2026-07-09
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for Grok 4.5 across multiple providers
|
||||
- Added support for GPT-5.6 series models (Luna, Sol, Terra)
|
||||
- Added Aion 3.0 and 3.0 Mini models
|
||||
- Added Kuaishou KAT-Coder v2.5 models
|
||||
- Added Nex-N2-Mini and SWE-1.7 series models
|
||||
- Added Hy3 models and free variants
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated cost and token configurations for various models across providers
|
||||
- Renamed several models for consistency (e.g., MiniMax M3, Gemma 4 31B, Qwen variants)
|
||||
|
||||
## [16.3.12] - 2026-07-08
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-catalog",
|
||||
"version": "16.3.12",
|
||||
"version": "16.3.14",
|
||||
"description": "Model catalog for omp: bundled model database, provider discovery descriptors, model identity, classification, and equivalence",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -13,6 +13,13 @@ const CURSOR_GET_USABLE_MODELS_PATH = "/agent.v1.AgentService/GetUsableModels";
|
||||
const DEFAULT_CONTEXT_WINDOW = 200_000;
|
||||
const DEFAULT_MAX_TOKENS = 64_000;
|
||||
|
||||
/**
|
||||
* Model-id families whose native catalogs (anthropic, openai/openai-codex,
|
||||
* google) are multimodal. Cursor-only or text-only families (`composer-*`,
|
||||
* `grok-code-*`) intentionally stay outside this pattern.
|
||||
*/
|
||||
const CURSOR_MULTIMODAL_ID_PATTERN = /claude|gemini|gpt-|codex/;
|
||||
|
||||
const OptionalDisplayNameSchema = type("unknown").pipe(raw => (typeof raw === "string" ? raw : undefined));
|
||||
const CursorAliasesSchema = type("unknown").pipe(raw => {
|
||||
if (Array.isArray(raw)) {
|
||||
@@ -292,7 +299,7 @@ function normalizeCursorModel(
|
||||
provider: "cursor",
|
||||
baseUrl: baseUrlOverride ?? CURSOR_DEFAULT_BASE_URL,
|
||||
reasoning,
|
||||
input: ["text"],
|
||||
input: inferInputFromCursorId(id),
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_MAX_TOKENS,
|
||||
@@ -312,3 +319,18 @@ function pickModelDisplayName(model: CursorModelDetailsValue, fallbackId: string
|
||||
}
|
||||
return fallbackId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infers input modalities for Cursor models without a bundled reference.
|
||||
*
|
||||
* `GetUsableModels` carries no per-model modality metadata, so classification
|
||||
* falls back to the model family: families that are multimodal in OMP's own
|
||||
* native catalogs accept images, everything else stays text-only. Mirrors
|
||||
* `inferInputFromGeminiId` in ./gemini.ts.
|
||||
*/
|
||||
function inferInputFromCursorId(id: string): ("text" | "image")[] {
|
||||
if (CURSOR_MULTIMODAL_ID_PATTERN.test(id.toLowerCase())) {
|
||||
return ["text", "image"];
|
||||
}
|
||||
return ["text"];
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export interface ModelManagerOptions<TApi extends Api = Api, TModelsDevPayload =
|
||||
cacheDbPath?: string;
|
||||
/** Optional provider id override for cache namespacing. Defaults to providerId. */
|
||||
cacheProviderId?: string;
|
||||
/** Maximum cache age in milliseconds before considered stale. Default: 24h. */
|
||||
/** Maximum cache age in milliseconds before considered stale. Default: 2h (`DEFAULT_CACHE_TTL_MS`). */
|
||||
cacheTtlMs?: number;
|
||||
/** When true, a successful dynamic fetch is the complete provider catalog and prunes static-only models. */
|
||||
dynamicModelsAuthoritative?: boolean;
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type ParsedModel,
|
||||
parseAnthropicModel,
|
||||
parseKnownModel,
|
||||
parseOpenAIModel,
|
||||
semverEqual,
|
||||
semverGte,
|
||||
} from "./identity/classify";
|
||||
@@ -101,12 +102,14 @@ const MIMO_REASONING_EFFORT_MAP: Readonly<EffortMap> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Effort → wire-value map for the 5-tier adaptive scale (Opus 4.7+ and
|
||||
* Fable/Mythos 5 on the Messages API). User-facing efforts shift up one notch
|
||||
* so the top tier reaches the genuine "max" and "high" lands on Anthropic's
|
||||
* recommended "xhigh" coding/agentic default.
|
||||
* Effort → wire-value map for a shifted five-tier scale (`low..max`):
|
||||
* user-facing efforts shift up one notch so the top tier reaches the genuine
|
||||
* "max" and "high" lands on the recommended "xhigh" coding/agentic default.
|
||||
* Used by Anthropic adaptive models with a real xhigh tier (Opus 4.7+ and
|
||||
* Fable/Mythos 5 on the Messages API) and by GPT-5.6+ wire-effort models,
|
||||
* which expose the same genuine `max` tier above `xhigh`.
|
||||
*/
|
||||
export const ANTHROPIC_ADAPTIVE_EFFORT_MAP_5_TIER: Readonly<Partial<Record<Effort, string>>> = {
|
||||
export const SHIFTED_FIVE_TIER_EFFORT_MAP: Readonly<Partial<Record<Effort, string>>> = {
|
||||
[Effort.Minimal]: "low",
|
||||
[Effort.Low]: "medium",
|
||||
[Effort.Medium]: "high",
|
||||
@@ -295,6 +298,27 @@ function isOpenAICompatReasoningApi(api: Api): boolean {
|
||||
return api === "openai-completions" || api === "openrouter";
|
||||
}
|
||||
|
||||
/**
|
||||
* GPT-5.6+ addressed through a wire `reasoning.effort`/`reasoning_effort`
|
||||
* field, where the shifted five-tier map applies. Devin (`devin-agent`)
|
||||
* selects effort by routing to per-tier sibling model ids instead and must
|
||||
* stay unmapped.
|
||||
*/
|
||||
function isGpt56PlusWireEffortModel<TApi extends Api>(spec: ModelSpec<TApi>): boolean {
|
||||
switch (spec.api) {
|
||||
case "openai-responses":
|
||||
case "openai-codex-responses":
|
||||
case "azure-openai-responses":
|
||||
case "openai-completions":
|
||||
case "openrouter":
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
const parsed = parseOpenAIModel(bareModelId(spec.id));
|
||||
return parsed !== null && semverGte(parsed.version, "5.6");
|
||||
}
|
||||
|
||||
function getModelDefinedEfforts<TApi extends Api>(
|
||||
spec: ModelSpec<TApi>,
|
||||
compat: CompatOf<TApi>,
|
||||
@@ -313,6 +337,12 @@ function getModelDefinedEfforts<TApi extends Api>(
|
||||
if (isSakanaFuguReasoningModel(spec)) {
|
||||
return FUGU_REASONING_EFFORTS;
|
||||
}
|
||||
if (isGpt56PlusWireEffortModel(spec)) {
|
||||
// Normalize stale baked/discovered `low..xhigh` surfaces to the full
|
||||
// five-tier ladder so the shifted map keeps the native `low` tier
|
||||
// reachable (user `minimal`).
|
||||
return DEFAULT_REASONING_EFFORTS_WITH_XHIGH;
|
||||
}
|
||||
return isOpenAICompatReasoningApi(spec.api) &&
|
||||
(isMinimaxM2FamilyModelId(spec.id) ||
|
||||
isOpenAIGptOssModelId(spec.id) ||
|
||||
@@ -373,7 +403,7 @@ function inferDetectedEffortMap<TApi extends Api>(
|
||||
return MINIMAX_ANTHROPIC_ADAPTIVE_EFFORT_MAP;
|
||||
}
|
||||
return anthropicModelHasRealXHighEffort(spec, parsedModel)
|
||||
? ANTHROPIC_ADAPTIVE_EFFORT_MAP_5_TIER
|
||||
? SHIFTED_FIVE_TIER_EFFORT_MAP
|
||||
: ANTHROPIC_ADAPTIVE_EFFORT_MAP_4_TIER;
|
||||
}
|
||||
// GLM-5.2 coding SKUs accept `reasoning_effort`, but the effort dialect is
|
||||
@@ -397,6 +427,9 @@ function inferDetectedEffortMap<TApi extends Api>(
|
||||
if (isSakanaFuguReasoningModel(spec)) {
|
||||
return FUGU_REASONING_EFFORT_MAP;
|
||||
}
|
||||
if (isGpt56PlusWireEffortModel(spec)) {
|
||||
return SHIFTED_FIVE_TIER_EFFORT_MAP;
|
||||
}
|
||||
if (!isOpenAICompatReasoningApi(spec.api)) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -446,7 +479,7 @@ function getOpenRouterAnthropicReasoningEffortMap(modelId: string): EffortMap |
|
||||
if (!isAnthropicAdaptiveGenAtLeast(parsed, "4.6")) return undefined;
|
||||
|
||||
const hasRealXHigh = isAnthropicAdaptiveGenAtLeast(parsed, "4.7");
|
||||
return hasRealXHigh ? ANTHROPIC_ADAPTIVE_EFFORT_MAP_5_TIER : ANTHROPIC_ADAPTIVE_EFFORT_MAP_4_TIER;
|
||||
return hasRealXHigh ? SHIFTED_FIVE_TIER_EFFORT_MAP : ANTHROPIC_ADAPTIVE_EFFORT_MAP_4_TIER;
|
||||
}
|
||||
|
||||
function inferSupportedEfforts<TApi extends Api>(
|
||||
@@ -474,6 +507,11 @@ function inferOpenAISupportedEfforts(model: OpenAIModel): readonly Effort[] {
|
||||
if (model.variant === "codex-mini" && semverEqual(model.version, "5.1")) {
|
||||
return GPT_5_1_CODEX_MINI_EFFORTS;
|
||||
}
|
||||
// 5.6+ exposes the full five-tier ladder: the shifted wire map spans
|
||||
// low..max, with user `minimal` reaching the native `low` tier.
|
||||
if (semverGte(model.version, "5.6")) {
|
||||
return DEFAULT_REASONING_EFFORTS_WITH_XHIGH;
|
||||
}
|
||||
if (semverGte(model.version, "5.2")) {
|
||||
return GPT_5_2_PLUS_EFFORTS;
|
||||
}
|
||||
|
||||
+1951
-184
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,14 @@ function thinkingPair(baseId: string, name: string): EffortVariantFamily {
|
||||
|
||||
type DevinTierRoutes = Partial<Record<"off" | "minimal" | "low" | "medium" | "high" | "xhigh", string>>;
|
||||
|
||||
const DEVIN_FIVE_TIER_EFFORTS: readonly Effort[] = [
|
||||
Effort.Minimal,
|
||||
Effort.Low,
|
||||
Effort.Medium,
|
||||
Effort.High,
|
||||
Effort.XHigh,
|
||||
];
|
||||
|
||||
function devinTierFamily(
|
||||
id: string,
|
||||
name: string,
|
||||
@@ -160,6 +168,44 @@ function devinTierFamily(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GPT-5.6 (Luna/Sol/Terra) adds a genuine `max` tier above `xhigh`, so the
|
||||
* standard family shifts every user effort up one notch (`minimal` → `-low`
|
||||
* … `xhigh` → `-max`), mirroring the Opus 4.7+ five-tier mapping. Devin
|
||||
* serves no `-max-priority` sibling, so the fast family keeps the direct
|
||||
* `low..xhigh` `-priority` scale.
|
||||
*/
|
||||
function devinGpt56Families(variant: "luna" | "sol" | "terra", name: string): readonly EffortVariantFamily[] {
|
||||
const base = `gpt-5-6-${variant}`;
|
||||
return [
|
||||
devinTierFamily(
|
||||
base,
|
||||
name,
|
||||
{
|
||||
off: `${base}-none`,
|
||||
minimal: `${base}-low`,
|
||||
low: `${base}-medium`,
|
||||
medium: `${base}-high`,
|
||||
high: `${base}-xhigh`,
|
||||
xhigh: `${base}-max`,
|
||||
},
|
||||
DEVIN_FIVE_TIER_EFFORTS,
|
||||
),
|
||||
devinTierFamily(
|
||||
`${base}-fast`,
|
||||
`${name} Fast`,
|
||||
{
|
||||
off: `${base}-none-priority`,
|
||||
low: `${base}-low-priority`,
|
||||
medium: `${base}-medium-priority`,
|
||||
high: `${base}-high-priority`,
|
||||
xhigh: `${base}-xhigh-priority`,
|
||||
},
|
||||
DEVIN_FIVE_TIER_EFFORTS,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const GEMINI_3_FLASH_FAMILY_EFFORTS: readonly Effort[] = [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High];
|
||||
const GEMINI_3_PRO_FAMILY_EFFORTS: readonly Effort[] = [Effort.Low, Effort.High];
|
||||
|
||||
@@ -330,7 +376,7 @@ export const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
|
||||
},
|
||||
thinking: {
|
||||
mode: "effort",
|
||||
efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
efforts: DEVIN_FIVE_TIER_EFFORTS,
|
||||
requiresEffort: true,
|
||||
},
|
||||
},
|
||||
@@ -353,7 +399,7 @@ export const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
|
||||
},
|
||||
thinking: {
|
||||
mode: "effort",
|
||||
efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
efforts: DEVIN_FIVE_TIER_EFFORTS,
|
||||
requiresEffort: true,
|
||||
},
|
||||
},
|
||||
@@ -376,7 +422,7 @@ export const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
|
||||
},
|
||||
thinking: {
|
||||
mode: "effort",
|
||||
efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
efforts: DEVIN_FIVE_TIER_EFFORTS,
|
||||
requiresEffort: true,
|
||||
},
|
||||
},
|
||||
@@ -399,7 +445,7 @@ export const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
|
||||
},
|
||||
thinking: {
|
||||
mode: "effort",
|
||||
efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
efforts: DEVIN_FIVE_TIER_EFFORTS,
|
||||
requiresEffort: true,
|
||||
},
|
||||
},
|
||||
@@ -413,7 +459,7 @@ export const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
|
||||
high: "MODEL_GPT_5_2_HIGH",
|
||||
xhigh: "MODEL_GPT_5_2_XHIGH",
|
||||
},
|
||||
[Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
DEVIN_FIVE_TIER_EFFORTS,
|
||||
),
|
||||
devinTierFamily(
|
||||
"gpt-5-3-codex",
|
||||
@@ -424,7 +470,7 @@ export const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
|
||||
high: "gpt-5-3-codex-high",
|
||||
xhigh: "gpt-5-3-codex-xhigh",
|
||||
},
|
||||
[Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
DEVIN_FIVE_TIER_EFFORTS,
|
||||
),
|
||||
devinTierFamily(
|
||||
"gpt-5-3-codex-fast",
|
||||
@@ -435,7 +481,7 @@ export const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
|
||||
high: "gpt-5-3-codex-high-priority",
|
||||
xhigh: "gpt-5-3-codex-xhigh-priority",
|
||||
},
|
||||
[Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
DEVIN_FIVE_TIER_EFFORTS,
|
||||
),
|
||||
devinTierFamily(
|
||||
"gpt-5-4",
|
||||
@@ -447,7 +493,7 @@ export const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
|
||||
high: "gpt-5-4-high",
|
||||
xhigh: "gpt-5-4-xhigh",
|
||||
},
|
||||
[Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
DEVIN_FIVE_TIER_EFFORTS,
|
||||
),
|
||||
devinTierFamily(
|
||||
"gpt-5-4-fast",
|
||||
@@ -459,7 +505,7 @@ export const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
|
||||
high: "gpt-5-4-high-priority",
|
||||
xhigh: "gpt-5-4-xhigh-priority",
|
||||
},
|
||||
[Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
DEVIN_FIVE_TIER_EFFORTS,
|
||||
),
|
||||
devinTierFamily(
|
||||
"gpt-5-4-mini",
|
||||
@@ -470,7 +516,7 @@ export const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
|
||||
high: "gpt-5-4-mini-high",
|
||||
xhigh: "gpt-5-4-mini-xhigh",
|
||||
},
|
||||
[Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
DEVIN_FIVE_TIER_EFFORTS,
|
||||
),
|
||||
devinTierFamily(
|
||||
"gpt-5-5",
|
||||
@@ -482,7 +528,7 @@ export const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
|
||||
high: "gpt-5-5-high",
|
||||
xhigh: "gpt-5-5-xhigh",
|
||||
},
|
||||
[Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
DEVIN_FIVE_TIER_EFFORTS,
|
||||
),
|
||||
devinTierFamily(
|
||||
"gpt-5-5-fast",
|
||||
@@ -494,8 +540,11 @@ export const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
|
||||
high: "gpt-5-5-high-priority",
|
||||
xhigh: "gpt-5-5-xhigh-priority",
|
||||
},
|
||||
[Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
DEVIN_FIVE_TIER_EFFORTS,
|
||||
),
|
||||
...devinGpt56Families("luna", "GPT-5.6 Luna"),
|
||||
...devinGpt56Families("sol", "GPT-5.6 Sol"),
|
||||
...devinGpt56Families("terra", "GPT-5.6 Terra"),
|
||||
devinTierFamily(
|
||||
"gemini-3-1-pro",
|
||||
"Gemini 3.1 Pro",
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import * as http2 from "node:http2";
|
||||
import { create, toBinary } from "@bufbuild/protobuf";
|
||||
// Import from source, not the package specifier: the workspace `node_modules`
|
||||
// copy resolves to the primary checkout, not this worktree.
|
||||
import { fetchCursorUsableModels } from "../src/discovery/cursor";
|
||||
import { GetUsableModelsResponseSchema, ModelDetailsSchema } from "../src/discovery/cursor-gen/agent_pb";
|
||||
import type { ModelSpec } from "../src/types";
|
||||
|
||||
const FIXTURE_MODEL_IDS = [
|
||||
// Reference-less ids from families whose native catalogs are multimodal.
|
||||
"claude-opus-4-8-99999999",
|
||||
"gpt-5.5-codex-20991231",
|
||||
"gemini-4-pro-exp",
|
||||
// Reference-less ids from text-only families.
|
||||
"composer-3",
|
||||
"grok-code-fast-2",
|
||||
// Bundled-reference ids: the reference stays authoritative.
|
||||
"claude-4.5-opus-high",
|
||||
"claude-4.6-opus-high",
|
||||
"composer-1",
|
||||
];
|
||||
|
||||
let server: http2.Http2Server;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const response = create(GetUsableModelsResponseSchema, {
|
||||
models: FIXTURE_MODEL_IDS.map(modelId => create(ModelDetailsSchema, { modelId })),
|
||||
});
|
||||
const payload = Buffer.from(toBinary(GetUsableModelsResponseSchema, response));
|
||||
|
||||
server = http2.createServer();
|
||||
server.on("stream", (stream: http2.ServerHttp2Stream, headers: http2.IncomingHttpHeaders) => {
|
||||
stream.on("data", () => {});
|
||||
stream.on("end", () => {
|
||||
if (headers[":path"] !== "/agent.v1.AgentService/GetUsableModels") {
|
||||
stream.respond({ ":status": 404 });
|
||||
stream.end();
|
||||
return;
|
||||
}
|
||||
stream.respond({ ":status": 200, "content-type": "application/proto" });
|
||||
stream.end(payload);
|
||||
});
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("expected http2 fixture server to bind a tcp port");
|
||||
}
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server?.close();
|
||||
});
|
||||
|
||||
async function discover(): Promise<Map<string, ModelSpec<"cursor-agent">>> {
|
||||
const models = await fetchCursorUsableModels({ apiKey: "test-key", baseUrl });
|
||||
expect(models).not.toBeNull();
|
||||
return new Map((models ?? []).map(model => [model.id, model]));
|
||||
}
|
||||
|
||||
describe("cursor discovery input modalities (issue #4726)", () => {
|
||||
it("classifies reference-less multimodal-family models as text+image", async () => {
|
||||
const byId = await discover();
|
||||
expect(byId.get("claude-opus-4-8-99999999")?.input).toEqual(["text", "image"]);
|
||||
expect(byId.get("gpt-5.5-codex-20991231")?.input).toEqual(["text", "image"]);
|
||||
expect(byId.get("gemini-4-pro-exp")?.input).toEqual(["text", "image"]);
|
||||
});
|
||||
|
||||
it("keeps reference-less text-only families text-only", async () => {
|
||||
const byId = await discover();
|
||||
expect(byId.get("composer-3")?.input).toEqual(["text"]);
|
||||
expect(byId.get("grok-code-fast-2")?.input).toEqual(["text"]);
|
||||
});
|
||||
|
||||
it("keeps bundled references authoritative for input modalities", async () => {
|
||||
const byId = await discover();
|
||||
// Bundled cursor references carry their own input classification; the
|
||||
// id-based inference must not override it in either direction.
|
||||
expect(byId.get("claude-4.5-opus-high")?.input).toEqual(["text", "image"]);
|
||||
expect(byId.get("claude-4.6-opus-high")?.input).toEqual(["text"]);
|
||||
expect(byId.get("composer-1")?.input).toEqual(["text"]);
|
||||
});
|
||||
|
||||
it("preserves fallback defaults for reference-less models", async () => {
|
||||
const byId = await discover();
|
||||
const spec = byId.get("claude-opus-4-8-99999999");
|
||||
expect(spec?.provider).toBe("cursor");
|
||||
expect(spec?.api).toBe("cursor-agent");
|
||||
expect(spec?.contextWindow).toBe(200_000);
|
||||
expect(spec?.maxTokens).toBe(64_000);
|
||||
expect(spec?.cost).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
||||
});
|
||||
});
|
||||
@@ -567,6 +567,90 @@ describe("model thinking derivation", () => {
|
||||
expect(getSupportedEfforts(model)).toEqual([]);
|
||||
expect(clampThinkingLevelForModel(model, Effort.High)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("bakes the GPT-5.6 shifted five-tier effort map on wire-effort APIs", () => {
|
||||
const codex = createModel({
|
||||
id: "gpt-5.6-sol",
|
||||
api: "openai-codex-responses",
|
||||
provider: "openai-codex",
|
||||
});
|
||||
|
||||
expect(codex.thinking).toEqual({
|
||||
mode: "effort",
|
||||
efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
effortMap: {
|
||||
minimal: "low",
|
||||
low: "medium",
|
||||
medium: "high",
|
||||
high: "xhigh",
|
||||
xhigh: "max",
|
||||
},
|
||||
});
|
||||
|
||||
// Stale baked four-tier metadata (caches/discovery) normalizes back to
|
||||
// the five-tier ladder with the map attached — the wire-defaults
|
||||
// backfill path — and namespaced OpenRouter ids parse.
|
||||
const staleOpenRouter = createModel({
|
||||
id: "openai/gpt-5.6-terra",
|
||||
api: "openrouter",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
thinking: {
|
||||
mode: "effort",
|
||||
efforts: [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
},
|
||||
});
|
||||
|
||||
expect(staleOpenRouter.thinking).toEqual({
|
||||
mode: "effort",
|
||||
efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
effortMap: {
|
||||
minimal: "low",
|
||||
low: "medium",
|
||||
medium: "high",
|
||||
high: "xhigh",
|
||||
xhigh: "max",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps pre-5.6 and Devin-routed GPT models off the shifted effort map", () => {
|
||||
const gpt55 = createModel({
|
||||
id: "gpt-5.5",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
});
|
||||
|
||||
expect(gpt55.thinking).toEqual({
|
||||
mode: "effort",
|
||||
efforts: [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
});
|
||||
expect(gpt55.thinking?.effortMap).toBeUndefined();
|
||||
|
||||
// Devin selects effort by routing to per-tier sibling model ids, never
|
||||
// via a wire reasoning.effort field — the shifted map must not attach.
|
||||
const devin = createModel({
|
||||
id: "gpt-5-6-sol",
|
||||
api: "devin-agent",
|
||||
provider: "devin",
|
||||
baseUrl: "https://server.codeium.com",
|
||||
thinking: {
|
||||
mode: "effort",
|
||||
efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
effortRouting: {
|
||||
off: "gpt-5-6-sol-none",
|
||||
minimal: "gpt-5-6-sol-low",
|
||||
low: "gpt-5-6-sol-medium",
|
||||
medium: "gpt-5-6-sol-high",
|
||||
high: "gpt-5-6-sol-xhigh",
|
||||
xhigh: "gpt-5-6-sol-max",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(devin.thinking?.effortMap).toBeUndefined();
|
||||
expect(devin.thinking?.efforts).toEqual([Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("model thinking runtime helpers", () => {
|
||||
|
||||
@@ -2,6 +2,31 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [16.3.14] - 2026-07-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed issue where unfinalized tool blocks could incorrectly pin the live-region scroll seam
|
||||
- Improved rendering of raw thinking blocks by stripping empty HTML comment noise
|
||||
- Fixed display of thinking blocks consisting entirely of hidden comment noise
|
||||
- Fixed gpt-5.6 reasoning summaries rendering literal `<!-- -->` sentinel lines in thinking blocks; empty HTML comments (and the unterminated `<!--` tail while streaming) are now dropped from the thinking display, and blocks reduced to pure comment noise are hidden entirely.
|
||||
|
||||
## [16.3.13] - 2026-07-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `read` and `grep` treating empty optional `selector` fields emitted by models as invalid selectors instead of behaving like omitted selectors. ([#4879](https://github.com/can1357/oh-my-pi/issues/4879))
|
||||
- Fixed `grep` explicit line selectors on directory searches so they filter each matched file by line number instead of aborting with a single-file-only error ([#4898](https://github.com/can1357/oh-my-pi/issues/4898)).
|
||||
- Fixed Read tool previews dropping explicit `selector` arguments, so line ranges and `raw` modifiers render in terminal read call titles again ([#4899](https://github.com/can1357/oh-my-pi/issues/4899)).
|
||||
- Fixed named profiles dropping default user keybindings from `~/.omp/agent/keybindings.*`; profile keybindings now inherit those defaults and override only the keys they define ([#4867](https://github.com/can1357/oh-my-pi/issues/4867)).
|
||||
- Fixed pi extensions calling `ctx.ui.addAutocompleteProvider(...)` crashing at load with `TypeError: ... is not a function` — a failure that, for extensions guarding init in one `try/catch` (e.g. `@ff-labs/pi-fff`), aborted the extension's entire initialization. `ExtensionUIContext` now implements pi's autocomplete-provider API: interactive mode stacks each registered factory on top of the built-in editor provider (re-applied on every slash-command refresh, with throwing or malformed factories skipped), while RPC/ACP/headless contexts accept the factory as a no-op. ([#4919](https://github.com/can1357/oh-my-pi/issues/4919))
|
||||
- Fixed bundled reviewer and plan subagents to inherit their model roles' explicit thinking effort suffixes instead of pinning `high` ([#4761](https://github.com/can1357/oh-my-pi/issues/4761)).
|
||||
- Built-in provider model discovery now refreshes an expired stored OAuth credential before an online refresh needs it, instead of silently skipping the provider. The refresh is scoped to the providers actually being discovered (`refreshProvider` cannot rotate unrelated credentials), fires under `online-if-uncached` only when the model manager will actually fetch, and offline discovery stays peek-only ([#4893](https://github.com/can1357/oh-my-pi/issues/4893)).
|
||||
- Fixed Escape during an active TUI prompt requiring a second press before canceling; the first Escape now aborts the streaming turn immediately. ([#4921](https://github.com/can1357/oh-my-pi/issues/4921))
|
||||
- Fixed the streamed `write` tool's collapsed pending tail preview leaving stale rows above the first partial-result frame in the TUI; the first result now replays the viewport like the SSH placeholder seam already did ([#4477](https://github.com/can1357/oh-my-pi/issues/4477))
|
||||
- Fixed first-run setup ignoring a pre-seeded `config.yaml`: the settings loader now treats `config.yml` and `config.yaml` as equivalent existing main config files, writes back to the existing extension, and only creates canonical `config.yml` for fresh installs. ([#4914](https://github.com/can1357/oh-my-pi/issues/4914))
|
||||
- Fixed extension `sendUserMessage()` without `deliverAs` surfacing `AgentBusyError` during active streams; omitted `deliverAs` now queues a steer through the normal prompt flow, and ACP/RPC skill-command prompts queue while streaming (RPC honors the prompt command's `streamingBehavior`, defaulting to steer) ([#4923](https://github.com/can1357/oh-my-pi/issues/4923)).
|
||||
|
||||
## [16.3.12] - 2026-07-08
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-coding-agent",
|
||||
"version": "16.3.12",
|
||||
"version": "16.3.14",
|
||||
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
TUI_KEYBINDINGS,
|
||||
KeybindingsManager as TuiKeybindingsManager,
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
|
||||
import { getActiveProfile, getAgentDir, getProfileRootDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
|
||||
import { JSONC, YAML } from "bun";
|
||||
|
||||
/**
|
||||
@@ -375,6 +375,12 @@ interface KeybindingsConfigPaths {
|
||||
writeBackPath: string;
|
||||
}
|
||||
|
||||
/** Controls inherited keybinding lookup when creating a manager for a named profile. */
|
||||
export interface KeybindingsCreateOptions {
|
||||
/** Default-profile agent directory whose keybindings are merged before profile-specific bindings. */
|
||||
inheritedAgentDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load raw config from a file synchronously.
|
||||
* Returns parsed JSON/YAML or null if file doesn't exist or is invalid.
|
||||
@@ -428,6 +434,48 @@ function resolveKeybindingsConfigPaths(agentDir: string): KeybindingsConfigPaths
|
||||
return { readPath: ymlPath, writeBackPath: ymlPath };
|
||||
}
|
||||
|
||||
function mergeKeybindingsConfig(
|
||||
inheritedConfig: KeybindingsConfig,
|
||||
profileConfig: KeybindingsConfig,
|
||||
): KeybindingsConfig {
|
||||
return { ...inheritedConfig, ...profileConfig };
|
||||
}
|
||||
|
||||
function resolveInheritedAgentDir(agentDir: string, options: KeybindingsCreateOptions): string | undefined {
|
||||
const inheritedAgentDir =
|
||||
options.inheritedAgentDir ?? (getActiveProfile() ? path.join(getProfileRootDir(undefined), "agent") : undefined);
|
||||
if (!inheritedAgentDir) return undefined;
|
||||
if (path.resolve(inheritedAgentDir) === path.resolve(agentDir)) return undefined;
|
||||
return inheritedAgentDir;
|
||||
}
|
||||
|
||||
function loadMergedKeybindingsConfig(
|
||||
agentDir: string,
|
||||
options: KeybindingsCreateOptions,
|
||||
): {
|
||||
config: KeybindingsConfig;
|
||||
profilePath: string;
|
||||
inheritedPath: string | undefined;
|
||||
} {
|
||||
const profilePaths = resolveKeybindingsConfigPaths(agentDir);
|
||||
const profile = loadKeybindingsConfig(profilePaths.readPath, profilePaths.writeBackPath);
|
||||
const inheritedAgentDir = resolveInheritedAgentDir(agentDir, options);
|
||||
if (!inheritedAgentDir) {
|
||||
return { config: profile.config, profilePath: profile.persistedPath, inheritedPath: undefined };
|
||||
}
|
||||
|
||||
const inheritedPaths = resolveKeybindingsConfigPaths(inheritedAgentDir);
|
||||
// Read-only: a named-profile process must never write migration output into
|
||||
// the default profile's agent dir. Name migration still applies in-memory;
|
||||
// the on-disk migration happens when the default profile itself launches.
|
||||
const inherited = loadKeybindingsConfig(inheritedPaths.readPath, undefined);
|
||||
return {
|
||||
config: mergeKeybindingsConfig(inherited.config, profile.config),
|
||||
profilePath: profile.persistedPath,
|
||||
inheritedPath: inherited.persistedPath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and migrate keybindings config.
|
||||
* Legacy JSON is read for compatibility, but successful write-back goes to YAML.
|
||||
@@ -499,22 +547,23 @@ function keyConfigValue(keys: KeyId[]): KeyId | KeyId[] {
|
||||
*/
|
||||
export class KeybindingsManager extends TuiKeybindingsManager {
|
||||
#configPath: string | undefined;
|
||||
#inheritedConfigPath: string | undefined;
|
||||
#userBindings: KeybindingsConfig;
|
||||
|
||||
constructor(userBindings: KeybindingsConfig = {}, configPath?: string) {
|
||||
constructor(userBindings: KeybindingsConfig = {}, configPath?: string, inheritedConfigPath?: string) {
|
||||
super(KEYBINDINGS, userBindings);
|
||||
this.#configPath = configPath;
|
||||
this.#inheritedConfigPath = inheritedConfigPath;
|
||||
this.#userBindings = userBindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from config file at agentDir/keybindings.yml.
|
||||
* Create from config files at agentDir/keybindings.yml and the default profile.
|
||||
* Legacy keybindings.json is migrated to keybindings.yml on load.
|
||||
*/
|
||||
static create(agentDir: string = getAgentDir()): KeybindingsManager {
|
||||
const { readPath, writeBackPath } = resolveKeybindingsConfigPaths(agentDir);
|
||||
const { config: userBindings, persistedPath } = KeybindingsManager.#loadFromFile(readPath, writeBackPath);
|
||||
const manager = new KeybindingsManager(userBindings, persistedPath);
|
||||
static create(agentDir: string = getAgentDir(), options: KeybindingsCreateOptions = {}): KeybindingsManager {
|
||||
const { config: userBindings, profilePath, inheritedPath } = loadMergedKeybindingsConfig(agentDir, options);
|
||||
const manager = new KeybindingsManager(userBindings, profilePath, inheritedPath);
|
||||
// Set globally so getKeybindings() returns this manager
|
||||
setKeybindings(manager);
|
||||
return manager;
|
||||
@@ -528,12 +577,15 @@ export class KeybindingsManager extends TuiKeybindingsManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload keybindings from the config file.
|
||||
* Reload keybindings from the config files.
|
||||
*/
|
||||
reload(): void {
|
||||
if (!this.#configPath) return;
|
||||
const { config } = KeybindingsManager.#loadFromFile(this.#configPath);
|
||||
this.setUserBindings(config);
|
||||
const { config: inheritedConfig } = this.#inheritedConfigPath
|
||||
? KeybindingsManager.#loadFromFile(this.#inheritedConfigPath)
|
||||
: { config: {} };
|
||||
const { config: profileConfig } = KeybindingsManager.#loadFromFile(this.#configPath);
|
||||
this.setUserBindings(mergeKeybindingsConfig(inheritedConfig, profileConfig));
|
||||
}
|
||||
|
||||
setUserBindings(userBindings: KeybindingsConfig): void {
|
||||
|
||||
@@ -54,6 +54,12 @@ const LOCAL_PROVIDER_PLACEHOLDERS = new Set<string>(["llama-cpp-local", "lm-stud
|
||||
* so a successful fast path does not leave an armed timeout signal for concurrent GC.
|
||||
*/
|
||||
const RUNTIME_DYNAMIC_MODEL_FETCH_TIMEOUT_MS = 15_000;
|
||||
// Built-in discovery preflight mirror of the catalog model-manager's private
|
||||
// cache timings (model-manager.ts: DEFAULT_CACHE_TTL_MS / NON_AUTHORITATIVE_RETRY_MS).
|
||||
// Built-in descriptors never override cacheTtlMs, so agreeing with these values
|
||||
// makes the OAuth-refresh preflight fire exactly when the manager will fetch.
|
||||
const BUILT_IN_DISCOVERY_CACHE_TTL_MS = 2 * 60 * 60 * 1000;
|
||||
const BUILT_IN_DISCOVERY_NON_AUTHORITATIVE_RETRY_MS = 5 * 60 * 1000;
|
||||
|
||||
import type { ApiKeyResolver, FetchImpl } from "@oh-my-pi/pi-ai";
|
||||
import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
|
||||
@@ -1560,12 +1566,11 @@ export class ModelRegistry {
|
||||
): Promise<BuiltInDiscoveryResult> {
|
||||
// Skip providers already handled by configured discovery (e.g. user-configured ollama with discovery.type)
|
||||
const configuredDiscoveryProviders = new Set(this.#discoverableProviders.map(p => p.provider));
|
||||
const managerOptions = (await this.#collectBuiltInModelManagerOptions()).filter(opts => {
|
||||
if (configuredDiscoveryProviders.has(opts.providerId)) {
|
||||
return false;
|
||||
}
|
||||
return providerFilter ? providerFilter.has(opts.providerId) : true;
|
||||
});
|
||||
const managerOptions = await this.#collectBuiltInModelManagerOptions(
|
||||
strategy,
|
||||
providerFilter,
|
||||
configuredDiscoveryProviders,
|
||||
);
|
||||
if (managerOptions.length === 0) {
|
||||
return { models: [], authoritativeProviders: new Set() };
|
||||
}
|
||||
@@ -1583,7 +1588,49 @@ export class ModelRegistry {
|
||||
return { models, authoritativeProviders };
|
||||
}
|
||||
|
||||
async #collectBuiltInModelManagerOptions(): Promise<ModelManagerOptions<Api>[]> {
|
||||
async #resolveBuiltInDiscoveryApiKey(
|
||||
providerId: string,
|
||||
strategy: ModelRefreshStrategy,
|
||||
cacheProviderId: string,
|
||||
): Promise<string | undefined> {
|
||||
const peekedKey = await this.#peekApiKeyForProvider(providerId);
|
||||
if (isAuthenticated(peekedKey) || strategy === "offline") {
|
||||
return peekedKey;
|
||||
}
|
||||
const oauthCredentials = getOAuthCredentialsForProvider(this.authStorage, providerId);
|
||||
if (oauthCredentials.length === 0) {
|
||||
return peekedKey;
|
||||
}
|
||||
if (strategy === "online-if-uncached") {
|
||||
// Mirror shouldFetchRemoteSources: built-in managers use the catalog's
|
||||
// default TTL, so only refresh when the manager will actually fetch.
|
||||
const cache = readModelCache<Api>(
|
||||
cacheProviderId,
|
||||
BUILT_IN_DISCOVERY_CACHE_TTL_MS,
|
||||
Date.now,
|
||||
this.#cacheDbPath,
|
||||
);
|
||||
const cacheAgeMs = cache ? Date.now() - cache.updatedAt : Number.POSITIVE_INFINITY;
|
||||
if (cache?.fresh && (cache.authoritative || cacheAgeMs < BUILT_IN_DISCOVERY_NON_AUTHORITATIVE_RETRY_MS)) {
|
||||
return peekedKey;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await this.getApiKeyForProvider(providerId);
|
||||
} catch (error) {
|
||||
logger.debug("OAuth refresh failed during model discovery preflight", {
|
||||
provider: providerId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return peekedKey;
|
||||
}
|
||||
}
|
||||
|
||||
async #collectBuiltInModelManagerOptions(
|
||||
strategy: ModelRefreshStrategy,
|
||||
providerFilter: ReadonlySet<string> | undefined,
|
||||
configuredDiscoveryProviders: ReadonlySet<string>,
|
||||
): Promise<ModelManagerOptions<Api>[]> {
|
||||
const specialProviderDescriptors: Array<{
|
||||
providerId: string;
|
||||
resolveKey: (value: string | undefined) => string | undefined;
|
||||
@@ -1622,20 +1669,33 @@ export class ModelRegistry {
|
||||
},
|
||||
];
|
||||
const disabledProviders = getDisabledProviderIdsFromSettings();
|
||||
const standardProviderDescriptors = PROVIDER_DESCRIPTORS.filter(
|
||||
descriptor => !disabledProviders.has(descriptor.providerId),
|
||||
const standardProviderDescriptors = PROVIDER_DESCRIPTORS.filter(descriptor => {
|
||||
if (disabledProviders.has(descriptor.providerId)) return false;
|
||||
if (configuredDiscoveryProviders.has(descriptor.providerId)) return false;
|
||||
return providerFilter ? providerFilter.has(descriptor.providerId) : true;
|
||||
});
|
||||
const enabledSpecialProviderDescriptors = specialProviderDescriptors.filter(descriptor => {
|
||||
if (disabledProviders.has(descriptor.providerId)) return false;
|
||||
if (configuredDiscoveryProviders.has(descriptor.providerId)) return false;
|
||||
return providerFilter ? providerFilter.has(descriptor.providerId) : true;
|
||||
});
|
||||
const standardProviderKeys = await Promise.all(
|
||||
standardProviderDescriptors.map(descriptor => {
|
||||
const discoveryBaseUrl =
|
||||
this.#runtimeProviderOverrides.get(descriptor.providerId)?.baseUrl ??
|
||||
this.#providerOverrides.get(descriptor.providerId)?.baseUrl ??
|
||||
this.getProviderBaseUrl(descriptor.providerId);
|
||||
const cacheProviderId =
|
||||
descriptor.createModelManagerOptions({ baseUrl: discoveryBaseUrl, fetch: this.#fetch })
|
||||
.cacheProviderId ?? descriptor.providerId;
|
||||
return this.#resolveBuiltInDiscoveryApiKey(descriptor.providerId, strategy, cacheProviderId);
|
||||
}),
|
||||
);
|
||||
const enabledSpecialProviderDescriptors = specialProviderDescriptors.filter(
|
||||
descriptor => !disabledProviders.has(descriptor.providerId),
|
||||
const specialKeys = await Promise.all(
|
||||
enabledSpecialProviderDescriptors.map(descriptor =>
|
||||
this.#resolveBuiltInDiscoveryApiKey(descriptor.providerId, strategy, descriptor.providerId),
|
||||
),
|
||||
);
|
||||
// Use peekApiKey to avoid OAuth token refresh during discovery.
|
||||
// The token is only needed if the dynamic fetch fires (cache miss),
|
||||
// and failures there are handled gracefully.
|
||||
const peekKey = (descriptor: { providerId: string }) => this.#peekApiKeyForProvider(descriptor.providerId);
|
||||
const [standardProviderKeys, specialKeys] = await Promise.all([
|
||||
Promise.all(standardProviderDescriptors.map(peekKey)),
|
||||
Promise.all(enabledSpecialProviderDescriptors.map(peekKey)),
|
||||
]);
|
||||
const options: ModelManagerOptions<Api>[] = [];
|
||||
for (let i = 0; i < standardProviderDescriptors.length; i++) {
|
||||
const descriptor = standardProviderDescriptors[i];
|
||||
@@ -1670,7 +1730,12 @@ export class ModelRegistry {
|
||||
}
|
||||
// Append runtime model managers registered by extensions via fetchDynamicModels.
|
||||
for (const { options: managerOpts } of this.#runtimeModelManagers.values()) {
|
||||
options.push(managerOpts);
|
||||
if (
|
||||
!configuredDiscoveryProviders.has(managerOpts.providerId) &&
|
||||
(!providerFilter || providerFilter.has(managerOpts.providerId))
|
||||
) {
|
||||
options.push(managerOpts);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
getProjectDir,
|
||||
isEnoent,
|
||||
logger,
|
||||
MAIN_CONFIG_FILENAMES,
|
||||
procmgr,
|
||||
setWorktreesDir,
|
||||
} from "@oh-my-pi/pi-utils";
|
||||
@@ -60,7 +61,7 @@ export interface RawSettings {
|
||||
export interface SettingsOptions {
|
||||
/** Current working directory for project settings discovery */
|
||||
cwd?: string;
|
||||
/** Agent directory for config.yml storage */
|
||||
/** Agent directory for config.yml/config.yaml storage */
|
||||
agentDir?: string;
|
||||
/** Don't persist to disk (for tests) */
|
||||
inMemory?: boolean;
|
||||
@@ -234,7 +235,7 @@ export class Settings {
|
||||
#storage: AgentStorage | null = null;
|
||||
|
||||
#configFiles: string[] = [];
|
||||
/** Global settings from config.yml */
|
||||
/** Global settings from config.yml/config.yaml */
|
||||
#global: RawSettings = {};
|
||||
/** Project settings from .claude/settings.yml etc */
|
||||
#project: RawSettings = {};
|
||||
@@ -264,7 +265,7 @@ export class Settings {
|
||||
private constructor(options: SettingsOptions = {}) {
|
||||
this.#cwd = path.normalize(options.cwd ?? getProjectDir());
|
||||
this.#agentDir = path.normalize(options.agentDir ?? getAgentDir());
|
||||
this.#configPath = options.inMemory ? null : path.join(this.#agentDir, "config.yml");
|
||||
this.#configPath = options.inMemory ? null : path.join(this.#agentDir, MAIN_CONFIG_FILENAMES[0]);
|
||||
this.#configFiles = options.configFiles?.map(file => path.resolve(this.#cwd, expandTilde(file))) ?? [];
|
||||
this.#persist = !options.inMemory && options.readOnly !== true;
|
||||
|
||||
@@ -458,6 +459,7 @@ export class Settings {
|
||||
inMemory: !this.#persist,
|
||||
});
|
||||
cloned.#storage = this.#storage;
|
||||
cloned.#configPath = this.#configPath;
|
||||
cloned.#global = structuredClone(this.#global);
|
||||
cloned.#project = this.#persist ? await cloned.#loadProjectSettings() : structuredClone(this.#project);
|
||||
cloned.#configFiles = [...this.#configFiles];
|
||||
@@ -676,16 +678,22 @@ export class Settings {
|
||||
|
||||
async #load(): Promise<Settings> {
|
||||
// Project settings load (loadCapability scans cwd) is independent of the
|
||||
// persist chain (storage open → legacy migration → global config.yml read),
|
||||
// so kick it off first and await after the persist chain completes. The
|
||||
// persist steps remain sequential: migration may write config.yml, which
|
||||
// #loadYaml then reads; migration's db fallback needs #storage opened.
|
||||
// persist chain (storage open → legacy migration → global config read), so
|
||||
// kick it off first and await after the persist chain completes. The
|
||||
// persist steps remain sequential: existing config discovery decides
|
||||
// whether migration may write config.yml before the global config is read;
|
||||
// migration's db fallback needs #storage opened.
|
||||
const projectPromise = this.#loadProjectSettings();
|
||||
|
||||
if (this.#persist) {
|
||||
this.#storage = await AgentStorage.open(getAgentDbPath(this.#agentDir));
|
||||
await this.#migrateFromLegacy();
|
||||
this.#global = await this.#loadYaml(this.#configPath!);
|
||||
const existingConfig = await this.#loadExistingMainYaml();
|
||||
if (existingConfig) {
|
||||
this.#global = existingConfig;
|
||||
} else {
|
||||
await this.#migrateFromLegacy();
|
||||
this.#global = await this.#loadYaml(this.#configPath!);
|
||||
}
|
||||
await this.#seedLastChangelogVersionMarker();
|
||||
}
|
||||
|
||||
@@ -701,8 +709,9 @@ export class Settings {
|
||||
async #loadReadOnly(): Promise<Settings> {
|
||||
const projectPromise = this.#loadProjectSettings();
|
||||
|
||||
if (this.#configPath) {
|
||||
this.#global = await this.#loadYaml(this.#configPath);
|
||||
const existingConfig = await this.#loadExistingMainYaml();
|
||||
if (existingConfig) {
|
||||
this.#global = existingConfig;
|
||||
}
|
||||
|
||||
this.#project = await projectPromise;
|
||||
@@ -712,20 +721,46 @@ export class Settings {
|
||||
}
|
||||
|
||||
async #loadYaml(filePath: string): Promise<RawSettings> {
|
||||
const loaded = await this.#loadYamlIfPresent(filePath);
|
||||
return loaded ?? {};
|
||||
}
|
||||
|
||||
async #loadYamlIfPresent(filePath: string): Promise<RawSettings | null> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await Bun.file(filePath).text();
|
||||
} catch (error) {
|
||||
if (isEnoent(error)) return null;
|
||||
logger.warn("Settings: failed to load", { path: filePath, error: String(error) });
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await Bun.file(filePath).text();
|
||||
const parsed = YAML.parse(content);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
return this.#migrateRawSettings(parsed as RawSettings);
|
||||
} catch (error) {
|
||||
if (isEnoent(error)) return {};
|
||||
logger.warn("Settings: failed to load", { path: filePath, error: String(error) });
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async #loadExistingMainYaml(): Promise<RawSettings | null> {
|
||||
if (!this.#configPath) return null;
|
||||
for (const filename of MAIN_CONFIG_FILENAMES) {
|
||||
const configPath = path.join(this.#agentDir, filename);
|
||||
const loaded = await this.#loadYamlIfPresent(configPath);
|
||||
if (loaded) {
|
||||
this.#configPath = configPath;
|
||||
return loaded;
|
||||
}
|
||||
}
|
||||
this.#configPath = path.join(this.#agentDir, MAIN_CONFIG_FILENAMES[0]);
|
||||
return null;
|
||||
}
|
||||
|
||||
async #loadProjectSettings(): Promise<RawSettings> {
|
||||
try {
|
||||
const result = await loadCapability(settingsCapability.id, { cwd: this.#cwd });
|
||||
@@ -781,14 +816,6 @@ export class Settings {
|
||||
async #migrateFromLegacy(): Promise<void> {
|
||||
if (!this.#configPath) return;
|
||||
|
||||
// Check if config.yml already exists
|
||||
try {
|
||||
await Bun.file(this.#configPath).text();
|
||||
return; // Already exists, no migration needed
|
||||
} catch (err) {
|
||||
if (!isEnoent(err)) return;
|
||||
}
|
||||
|
||||
let settings: RawSettings = {};
|
||||
let migrated = false;
|
||||
|
||||
|
||||
@@ -207,6 +207,7 @@ const noOpUIContext: ExtensionUIContext = {
|
||||
pasteToEditor: () => {},
|
||||
getEditorText: () => "",
|
||||
editor: async () => undefined,
|
||||
addAutocompleteProvider: () => {},
|
||||
setEditorComponent: () => {},
|
||||
get theme() {
|
||||
return theme;
|
||||
|
||||
@@ -30,7 +30,7 @@ import type {
|
||||
TSchema,
|
||||
} from "@oh-my-pi/pi-ai";
|
||||
import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/oauth/types";
|
||||
import type { AutocompleteItem, Component, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui";
|
||||
import type { AutocompleteItem, AutocompleteProvider, Component, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui";
|
||||
import type { logger as PiLogger } from "@oh-my-pi/pi-utils";
|
||||
import type { Type as arktype } from "arktype";
|
||||
import type * as zod from "zod/v4";
|
||||
@@ -163,6 +163,9 @@ export type ExtensionUiComponent = Component & { dispose?(): void };
|
||||
export type ExtensionUiComponentFactory = (tui: TUI, theme: Theme) => ExtensionUiComponent;
|
||||
export type ExtensionWidgetContent = string[] | ExtensionUiComponentFactory | undefined;
|
||||
|
||||
/** Wrap the current autocomplete provider with additional behavior (pi-compatible). */
|
||||
export type AutocompleteProviderFactory = (current: AutocompleteProvider) => AutocompleteProvider;
|
||||
|
||||
/**
|
||||
* UI context for extensions to request interactive UI.
|
||||
* Each mode (interactive, RPC, print) provides its own implementation.
|
||||
@@ -243,6 +246,14 @@ export interface ExtensionUIContext {
|
||||
editorOptions?: { promptStyle?: boolean },
|
||||
): Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Stack additional autocomplete behavior on top of the built-in provider
|
||||
* (pi-compatible). Interactive mode rebuilds the editor's provider through
|
||||
* every registered factory, in registration order; headless modes (print,
|
||||
* RPC, ACP, subagents) accept and ignore the factory.
|
||||
*/
|
||||
addAutocompleteProvider(factory: AutocompleteProviderFactory): void;
|
||||
|
||||
/**
|
||||
* Set a custom editor component via factory function, or `undefined` to restore the default editor.
|
||||
*
|
||||
@@ -1107,7 +1118,7 @@ export interface ExtensionAPI {
|
||||
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
|
||||
): void;
|
||||
|
||||
/** Send a user message to the agent, or queue it when deliverAs is set. */
|
||||
/** Send a user prompt: idle starts a turn; streaming queues as steer unless deliverAs is set. */
|
||||
sendUserMessage(
|
||||
content: string | (TextContent | ImageContent)[],
|
||||
options?: { deliverAs?: "steer" | "followUp" },
|
||||
|
||||
@@ -108,11 +108,16 @@ export interface MnemopiMemoryEditOptions {
|
||||
}
|
||||
|
||||
export interface MnemopiMemoryEditResult {
|
||||
status: "updated" | "deleted" | "invalidated" | "not_found";
|
||||
status: "updated" | "deleted" | "invalidated" | "not_found" | "not_editable";
|
||||
bank?: string;
|
||||
store?: "working" | "episodic";
|
||||
store?: MnemopiMemoryStore;
|
||||
}
|
||||
|
||||
/** Which mnemopi table a resolved memory id lives in. `fact` rows are
|
||||
* read-only projections of fact extraction (issue #4725): resolvable for
|
||||
* reads, never editable. */
|
||||
export type MnemopiMemoryStore = "working" | "episodic" | "fact";
|
||||
|
||||
interface MnemopiStoredMemoryRow {
|
||||
id?: unknown;
|
||||
content?: unknown;
|
||||
@@ -136,7 +141,7 @@ interface MnemopiStoredMemoryRow {
|
||||
*/
|
||||
export interface MnemopiScopedMemoryHit {
|
||||
bank: string;
|
||||
store: "working" | "episodic";
|
||||
store: MnemopiMemoryStore;
|
||||
row: {
|
||||
id: string;
|
||||
content: string;
|
||||
@@ -256,7 +261,8 @@ export class MnemopiSessionState {
|
||||
for (const target of targets) {
|
||||
const raw = target.memory.get(id) as MnemopiStoredMemoryRow | null;
|
||||
if (!raw) continue;
|
||||
const store: MnemopiScopedMemoryHit["store"] = raw.memory_store === "episodic" ? "episodic" : "working";
|
||||
const store: MnemopiMemoryStore =
|
||||
raw.memory_store === "episodic" || raw.memory_store === "fact" ? raw.memory_store : "working";
|
||||
return {
|
||||
bank: target.bank,
|
||||
store,
|
||||
@@ -291,8 +297,16 @@ export class MnemopiSessionState {
|
||||
for (const target of targets) {
|
||||
const row = target.memory.get(id) as MnemopiStoredMemoryRow | null;
|
||||
if (!row) continue;
|
||||
const store: MnemopiMemoryEditResult["store"] = row.memory_store === "episodic" ? "episodic" : "working";
|
||||
const store: MnemopiMemoryStore =
|
||||
row.memory_store === "episodic" || row.memory_store === "fact" ? row.memory_store : "working";
|
||||
const resultContext: Pick<MnemopiMemoryEditResult, "bank" | "store"> = { bank: target.bank, store };
|
||||
if (store === "fact") {
|
||||
// Facts are read-only: no memory_edit op mutates the facts
|
||||
// table, so report that precisely instead of `not_found`
|
||||
// (the id DID resolve — issue #4725).
|
||||
ineligible ??= { status: "not_editable", ...resultContext };
|
||||
continue;
|
||||
}
|
||||
if ((op === "update" || op === "forget") && store !== "working") {
|
||||
ineligible ??= { status: "not_found", ...resultContext };
|
||||
continue;
|
||||
|
||||
@@ -84,6 +84,7 @@ import { canonicalizeMessage } from "../../utils/thinking-display";
|
||||
import { createAcpClientBridge } from "./acp-client-bridge";
|
||||
import {
|
||||
buildToolCallStartUpdate,
|
||||
extractAssistantMessageText,
|
||||
mapAgentSessionEventToAcpSessionUpdates,
|
||||
normalizeReplayToolArguments,
|
||||
} from "./acp-event-mapper";
|
||||
@@ -425,6 +426,7 @@ export function createAcpExtensionUiContext(
|
||||
setEditorText: () => {},
|
||||
getEditorText: () => "",
|
||||
editor: async () => undefined,
|
||||
addAutocompleteProvider: () => {},
|
||||
setEditorComponent: () => {},
|
||||
get theme() {
|
||||
return theme;
|
||||
@@ -843,13 +845,16 @@ export class AcpAgent implements Agent {
|
||||
return false;
|
||||
}
|
||||
const built = await buildSkillPromptMessage(skill, parsed.args, "user");
|
||||
await record.session.promptCustomMessage({
|
||||
customType: SKILL_PROMPT_MESSAGE_TYPE,
|
||||
content: built.message,
|
||||
display: true,
|
||||
details: built.details,
|
||||
attribution: "user",
|
||||
});
|
||||
await record.session.promptCustomMessage(
|
||||
{
|
||||
customType: SKILL_PROMPT_MESSAGE_TYPE,
|
||||
content: built.message,
|
||||
display: true,
|
||||
details: built.details,
|
||||
attribution: "user",
|
||||
},
|
||||
{ streamingBehavior: "steer" },
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1210,8 +1215,11 @@ export class AcpAgent implements Agent {
|
||||
this.#clearLiveAssistantMessageAfterEvent(record, event);
|
||||
|
||||
if (event.type === "agent_end") {
|
||||
await this.#flushMissedFinalAssistantText(record, event);
|
||||
await this.#emitEndOfTurnUpdates(record);
|
||||
await this.#waitForAcpPromptIdle(record);
|
||||
record.liveMessageId = undefined;
|
||||
record.liveMessageProgress = undefined;
|
||||
this.#finishPrompt(record, {
|
||||
stopReason: this.#resolveStopReason(event, promptTurn.cancelRequested),
|
||||
usage: this.#buildTurnUsage(promptTurn.usageBaseline, record.session.sessionManager.getUsageStatistics()),
|
||||
@@ -1219,6 +1227,51 @@ export class AcpAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver the final visible answer when the assistant `message_end` never
|
||||
* reached this prompt turn's subscription. Session event handlers are
|
||||
* fire-and-forget (`Agent#emit` does not await async listeners), and
|
||||
* `agent_end` is flushed through the session's `#endInFlight` path while the
|
||||
* assistant `message_end` fan-out can still be parked on extension delivery —
|
||||
* so `agent_end` can overtake `message_end`. Once the turn finishes,
|
||||
* `#finishPrompt` unsubscribes and the fallback text emission in
|
||||
* `mapAssistantMessageEnd` is lost for good: a client that only received
|
||||
* `agent_thought_chunk`s stays stuck on the thinking block (#4902). The live
|
||||
* message progress records whether visible text ever reached the client; if
|
||||
* it has not, emit the last assistant message's text before the prompt
|
||||
* resolves. A `message_end` that lands during the end-of-turn waits still
|
||||
* takes the normal mapper path and sees `textEmitted` already set, so the
|
||||
* answer is delivered exactly once.
|
||||
*/
|
||||
async #flushMissedFinalAssistantText(
|
||||
record: ManagedSessionRecord,
|
||||
event: Extract<AgentSessionEvent, { type: "agent_end" }>,
|
||||
): Promise<void> {
|
||||
const progress = record.liveMessageProgress;
|
||||
if (!progress || progress.textEmitted) {
|
||||
return;
|
||||
}
|
||||
const lastAssistant = [...event.messages]
|
||||
.reverse()
|
||||
.find((message): message is AssistantMessage => message.role === "assistant");
|
||||
if (!lastAssistant) {
|
||||
return;
|
||||
}
|
||||
const text = extractAssistantMessageText(lastAssistant);
|
||||
if (text.length === 0) {
|
||||
return;
|
||||
}
|
||||
progress.textEmitted = true;
|
||||
await this.#connection.sessionUpdate({
|
||||
sessionId: record.session.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text },
|
||||
messageId: record.liveMessageId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async #waitForAcpPromptIdle(record: ManagedSessionRecord): Promise<void> {
|
||||
for (let pass = 0; pass < ACP_ASYNC_DELIVERY_DRAIN_MAX_PASSES; pass++) {
|
||||
await record.session.waitForIdle();
|
||||
@@ -1244,8 +1297,16 @@ export class AcpAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset live-message tracking once the assistant `message_end` is handled.
|
||||
* The `agent_end` reset happens inside the `agent_end` branch of
|
||||
* `#handlePromptEvent` — after `#flushMissedFinalAssistantText` — so a
|
||||
* `message_end` that arrives during the end-of-turn waits maps against the
|
||||
* real progress instead of resurrecting a fresh one (which would double-emit
|
||||
* the final answer).
|
||||
*/
|
||||
#clearLiveAssistantMessageAfterEvent(record: ManagedSessionRecord, event: AgentSessionEvent): void {
|
||||
if ((event.type === "message_end" && event.message.role === "assistant") || event.type === "agent_end") {
|
||||
if (event.type === "message_end" && event.message.role === "assistant") {
|
||||
record.liveMessageId = undefined;
|
||||
record.liveMessageProgress = undefined;
|
||||
}
|
||||
|
||||
@@ -922,7 +922,7 @@ function isTerminalOnlyDetails(value: unknown): boolean {
|
||||
return content === undefined || (Array.isArray(content) && content.length === 0);
|
||||
}
|
||||
|
||||
function extractAssistantMessageText(value: unknown): string {
|
||||
export function extractAssistantMessageText(value: unknown): string {
|
||||
if (typeof value !== "object" || value === null || !("content" in value)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export function readArgsTargetInternalUrl(args: unknown): boolean {
|
||||
type ReadRenderArgs = {
|
||||
path?: string;
|
||||
file_path?: string;
|
||||
selector?: string;
|
||||
// Legacy field from the old schema; tolerated for rebuilt transcripts.
|
||||
sel?: string;
|
||||
};
|
||||
@@ -344,7 +345,10 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
|
||||
updateArgs(args: ReadRenderArgs, toolCallId?: string): void {
|
||||
if (!toolCallId) return;
|
||||
const basePath = args.file_path || args.path || "";
|
||||
const rawPath = args.sel ? `${basePath}:${args.sel}` : basePath;
|
||||
const rawSelector =
|
||||
typeof args.selector === "string" ? args.selector : typeof args.sel === "string" ? args.sel : undefined;
|
||||
const selector = rawSelector?.trim().replace(/^:+/, "");
|
||||
const rawPath = selector && selector.length > 0 ? `${basePath}:${selector}` : basePath;
|
||||
const entry: ReadEntry = this.#entries.get(toolCallId) ?? {
|
||||
toolCallId,
|
||||
path: rawPath,
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
resolveImageOptions,
|
||||
truncateToWidth,
|
||||
} from "../../tools/render-utils";
|
||||
import { toolRenderers } from "../../tools/renderers";
|
||||
import { type FirstResultViewportRepaint, toolRenderers } from "../../tools/renderers";
|
||||
import { TODO_STRIKE_TOTAL_FRAMES, type TodoToolDetails } from "../../tools/todo";
|
||||
import { isFramedBlockComponent, renderStatusLine, WidthAwareText } from "../../tui";
|
||||
import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
|
||||
@@ -283,13 +283,13 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
||||
// history, so progress renders static gray and further partial snapshots are
|
||||
// dropped (see #maybeFreezeBackgroundTask).
|
||||
#backgroundTaskFrozen = false;
|
||||
// Set on each `render()` when the last painted shape carried the streamed
|
||||
// SSH-style placeholder / partial-result chrome. Reset gates key off these
|
||||
// so a topology-changing update that lands before the shape reaches the
|
||||
// terminal never triggers a full-viewport replay (which on direct terminals
|
||||
// wipes native scrollback and flashes the user's history — reviewer note on
|
||||
// PR #4315).
|
||||
#placeholderShapePainted = false;
|
||||
// Set on each `render()` when the last painted pending shape must be
|
||||
// replayed wholesale when the first result arrives. Reset gates key off
|
||||
// these so a topology-changing update that lands before the shape reaches
|
||||
// the terminal never triggers a full-viewport replay (which on direct
|
||||
// terminals wipes native scrollback and flashes the user's history —
|
||||
// reviewer note on PR #4315).
|
||||
#firstResultViewportRepaintShapePainted = false;
|
||||
#partialResultShapePainted = false;
|
||||
#renderState: {
|
||||
spinnerFrame?: number;
|
||||
@@ -497,9 +497,9 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
||||
}
|
||||
const hadNoResult = this.#result === undefined;
|
||||
const wasPartialResult = this.#result !== undefined && this.#isPartial;
|
||||
const placeholderPainted = this.#placeholderShapePainted;
|
||||
const firstResultRepaintShapePainted = this.#firstResultViewportRepaintShapePainted;
|
||||
const partialResultPainted = this.#partialResultShapePainted;
|
||||
this.#placeholderShapePainted = false;
|
||||
this.#firstResultViewportRepaintShapePainted = false;
|
||||
this.#partialResultShapePainted = false;
|
||||
this.#result = result;
|
||||
this.#resultVersion++;
|
||||
@@ -513,7 +513,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
||||
this.#updateTodoStrikeAnimation();
|
||||
this.#updateDisplay();
|
||||
this.#resetDisplayForResultTopologyChange(
|
||||
hadNoResult && placeholderPainted,
|
||||
hadNoResult && firstResultRepaintShapePainted,
|
||||
wasPartialResult && partialResultPainted,
|
||||
isPartial,
|
||||
);
|
||||
@@ -810,34 +810,38 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
||||
this.#displayBuilt = true;
|
||||
}
|
||||
|
||||
#rendererFlag(name: "forceFirstResultViewportRepaint" | "forceResultViewportRepaintOnSettle"): boolean {
|
||||
#rendererFlag(name: "forceResultViewportRepaintOnSettle"): boolean {
|
||||
const toolValue = (this.#tool as Record<string, unknown> | undefined)?.[name];
|
||||
const rendererValue = toolRenderers[this.#toolName]?.[name];
|
||||
return toolValue === true || (toolValue === undefined && rendererValue === true);
|
||||
}
|
||||
|
||||
/**
|
||||
* True while the last painted shape uses the streamed placeholder path
|
||||
* (`⏳ SSH: […]` / `$ …`) — the render call ran with `__partialJson` args
|
||||
* and no result. Kept as a per-paint fact so a topology-changing update
|
||||
* that lands before the placeholder reaches the terminal skips the reset.
|
||||
* True while the last painted pending-call shape opted into a full viewport
|
||||
* repaint at the first result (`forceFirstResultViewportRepaint`) — e.g. the
|
||||
* streamed SSH placeholder (`⏳ SSH: […]` / `$ …`) or a collapsed write tail
|
||||
* window, both of which the first result render re-anchors instead of
|
||||
* preserving. Kept as a per-paint fact so a topology-changing update that
|
||||
* lands before the pending rows reach the terminal skips the reset.
|
||||
*/
|
||||
#isPlaceholderShapeAtRender(): boolean {
|
||||
#needsFirstResultViewportRepaintAtRender(): boolean {
|
||||
if (this.#result !== undefined) return false;
|
||||
if (!this.#rendererFlag("forceFirstResultViewportRepaint")) return false;
|
||||
return partialJsonOf(this.#args) !== undefined;
|
||||
const toolValue = (this.#tool as { forceFirstResultViewportRepaint?: FirstResultViewportRepaint } | undefined)
|
||||
?.forceFirstResultViewportRepaint;
|
||||
const value =
|
||||
toolValue !== undefined ? toolValue : toolRenderers[this.#toolName]?.forceFirstResultViewportRepaint;
|
||||
if (typeof value === "function") return value(this.#args, this.#renderState);
|
||||
return value === true;
|
||||
}
|
||||
|
||||
#resetDisplayForResultTopologyChange(
|
||||
firstResultAfterPlaceholderPaint: boolean,
|
||||
firstResultAfterRepaintShapePaint: boolean,
|
||||
partialResultPaintedBeforeSettle: boolean,
|
||||
isPartial: boolean,
|
||||
): void {
|
||||
const firstResultReplacesStreamedPlaceholder =
|
||||
firstResultAfterPlaceholderPaint && this.#rendererFlag("forceFirstResultViewportRepaint");
|
||||
const provisionalResultSettled =
|
||||
partialResultPaintedBeforeSettle && !isPartial && this.#rendererFlag("forceResultViewportRepaintOnSettle");
|
||||
if (firstResultReplacesStreamedPlaceholder || provisionalResultSettled) {
|
||||
if (firstResultAfterRepaintShapePaint || provisionalResultSettled) {
|
||||
this.#ui.resetDisplay();
|
||||
}
|
||||
}
|
||||
@@ -848,7 +852,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
||||
// override runs on every compose the parent Container performs, so a
|
||||
// frame that never gets composed leaves the flags false and prevents a
|
||||
// spurious `resetDisplay()`.
|
||||
this.#placeholderShapePainted = this.#isPlaceholderShapeAtRender();
|
||||
this.#firstResultViewportRepaintShapePainted = this.#needsFirstResultViewportRepaintAtRender();
|
||||
this.#partialResultShapePainted = this.#result !== undefined && this.#isPartial;
|
||||
return lines;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,18 @@ interface FinalizableBlock {
|
||||
* commits until the block finalizes.
|
||||
*/
|
||||
getTranscriptBlockSettledRows?(): number;
|
||||
/**
|
||||
* Whether the block is a displaceable snapshot (todo/poll card) kept
|
||||
* unfinalized only so a follow-up matching call can retract it. Paired
|
||||
* with {@link seal}: once any of its rows enters native scrollback the
|
||||
* container seals it — rows on the tape are immutable, so retraction is
|
||||
* no longer possible, and an unfinalized block would otherwise pin the
|
||||
* live-region seam open for the rest of the turn (every row committed
|
||||
* below it audit-exempt, mass-recommitted when it finally finalizes).
|
||||
*/
|
||||
isDisplaceableBlock?(): boolean;
|
||||
/** Finalize a displaceable snapshot in place (settle animation, freeze bytes). */
|
||||
seal?(): void;
|
||||
}
|
||||
|
||||
function isBlockFinalized(child: Component): boolean {
|
||||
@@ -60,6 +72,12 @@ function getBlockSettledRows(child: Component): number {
|
||||
return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
|
||||
}
|
||||
|
||||
/** Seal a displaceable snapshot whose rows entered native scrollback (see {@link FinalizableBlock.isDisplaceableBlock}). */
|
||||
function sealCommittedSnapshot(child: Component): void {
|
||||
const block = child as Component & FinalizableBlock;
|
||||
if (block.isDisplaceableBlock?.()) block.seal?.();
|
||||
}
|
||||
|
||||
// A "plain blank" row is empty or whitespace-only with no ANSI bytes. It marks
|
||||
// separation padding (a `Spacer`, or a no-background `paddingY` row) as opposed
|
||||
// to a background-colored padding row, whose escape sequences contain `\S` and
|
||||
@@ -275,6 +293,21 @@ export class TranscriptContainer
|
||||
|
||||
const count = this.children.length;
|
||||
|
||||
// Seal displaceable snapshots whose rows are already on the tape (per the
|
||||
// previous frame's segments — the geometry the committed count was
|
||||
// computed against): immutable history can no longer be retracted, and
|
||||
// left unfinalized such a block would pin the live-region seam open below
|
||||
// it. Runs before the live-block scan so the seam unpins in this same
|
||||
// frame, and every frame so a block that BECAME displaceable after its
|
||||
// pending-preview rows committed (late result on a scrolled-off call) is
|
||||
// caught too.
|
||||
for (let i = 0; i < count && i < this.#segments.length; i++) {
|
||||
const previous = this.#segments[i]!;
|
||||
if (previous.startRow >= this.#committedRows) break;
|
||||
if (previous.rowCount === 0 || previous.component !== this.children[i]) continue;
|
||||
sealCommittedSnapshot(previous.component);
|
||||
}
|
||||
|
||||
// The commit boundary stops at the earliest still-mutating block. A
|
||||
// block that has not finalized must gate it: out-of-band inserts
|
||||
// (TTSR/todo cards) can append a finalized block *below* a tool that is
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ExtensionUiController } from "./extension-ui-controller";
|
||||
function makeHarness() {
|
||||
const editor = new CustomEditor(getEditorTheme());
|
||||
const requestRender = vi.fn();
|
||||
const addAutocompleteProvider = vi.fn();
|
||||
let uiContext: ExtensionUIContext | undefined;
|
||||
const ctx = {
|
||||
editor,
|
||||
@@ -21,11 +22,13 @@ function makeHarness() {
|
||||
expect(hasUI).toBe(true);
|
||||
uiContext = context;
|
||||
},
|
||||
addAutocompleteProvider,
|
||||
} as unknown as InteractiveModeContext;
|
||||
|
||||
return {
|
||||
editor,
|
||||
requestRender,
|
||||
addAutocompleteProvider,
|
||||
async init(): Promise<ExtensionUIContext> {
|
||||
await new ExtensionUiController(ctx).initHooksAndCustomTools();
|
||||
expect(uiContext).toBeDefined();
|
||||
@@ -55,4 +58,17 @@ describe("ExtensionUiController editor UI", () => {
|
||||
expect(harness.editor.getText()).toBe("hello");
|
||||
expect(harness.requestRender).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("bridges addAutocompleteProvider factories to the interactive mode context (#4919)", async () => {
|
||||
const harness = makeHarness();
|
||||
const ui = await harness.init();
|
||||
|
||||
expect(typeof ui.addAutocompleteProvider).toBe("function");
|
||||
|
||||
const factory = (current: unknown) => current as never;
|
||||
ui.addAutocompleteProvider(factory);
|
||||
|
||||
expect(harness.addAutocompleteProvider).toHaveBeenCalledTimes(1);
|
||||
expect(harness.addAutocompleteProvider).toHaveBeenCalledWith(factory);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,6 +82,7 @@ export class ExtensionUiController {
|
||||
getEditorText: () => this.ctx.editor.getText(),
|
||||
editor: (title, prefill, dialogOptions, editorOptions) =>
|
||||
this.showCollabAwareEditor(title, prefill, dialogOptions, editorOptions),
|
||||
addAutocompleteProvider: factory => this.ctx.addAutocompleteProvider(factory),
|
||||
get theme() {
|
||||
return theme;
|
||||
},
|
||||
|
||||
@@ -154,7 +154,6 @@ const TINY_TITLE_PROGRESS_REVEAL_DELAY_MS = 1_000;
|
||||
// deliberate human double-tap is always tens of milliseconds apart.
|
||||
const LEFT_DOUBLE_TAP_MIN_GAP_MS = 40;
|
||||
const LEFT_DOUBLE_TAP_MAX_GAP_MS = 500;
|
||||
const STREAMING_ESCAPE_CANCEL_WINDOW_MS = 2_000;
|
||||
|
||||
export class InputController {
|
||||
constructor(
|
||||
@@ -179,16 +178,6 @@ export class InputController {
|
||||
// (>= LEFT_DOUBLE_TAP_MAX_GAP_MS) starts a fresh sequence. See
|
||||
// #detectLeftDoubleTap.
|
||||
#leftTapCount = 0;
|
||||
// Streaming turns use a two-step Esc: first press arms this token, second press
|
||||
// within the window aborts the same live assistant turn. The token is a per-turn
|
||||
// sentinel minted lazily on demand and reset on every `agent_start`/`agent_end`
|
||||
// (see setupKeyHandlers), so it survives `message_start`/`message_update`
|
||||
// transitions inside a single turn but cannot leak across turn boundaries.
|
||||
#streamingEscapeTurnSentinel: object | undefined;
|
||||
#streamingEscapeArmedToken: object | undefined;
|
||||
#streamingEscapeArmedUntil = 0;
|
||||
#streamingEscapeTimer: NodeJS.Timeout | undefined;
|
||||
#streamingEscapeSessionSubscribed = false;
|
||||
// Sequential index for `local://attachment-N` references created by large-paste and
|
||||
// pasted-file attachments. Seeded from 0 and bumped past existing attachment files.
|
||||
#attachmentCounter = 0;
|
||||
@@ -238,50 +227,12 @@ export class InputController {
|
||||
const unsubscribe = tinyTitleClient.onProgress(update);
|
||||
}
|
||||
|
||||
#clearStreamingEscapeArm(): void {
|
||||
this.#streamingEscapeArmedToken = undefined;
|
||||
this.#streamingEscapeArmedUntil = 0;
|
||||
if (this.#streamingEscapeTimer) {
|
||||
clearTimeout(this.#streamingEscapeTimer);
|
||||
this.#streamingEscapeTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
#handleStreamingEscape(): void {
|
||||
if (!this.#streamingEscapeTurnSentinel) {
|
||||
this.#streamingEscapeTurnSentinel = {};
|
||||
}
|
||||
const token = this.#streamingEscapeTurnSentinel;
|
||||
const now = Date.now();
|
||||
if (this.#streamingEscapeArmedToken === token && now <= this.#streamingEscapeArmedUntil) {
|
||||
this.#clearStreamingEscapeArm();
|
||||
void this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
|
||||
return;
|
||||
}
|
||||
|
||||
this.#clearStreamingEscapeArm();
|
||||
this.#streamingEscapeArmedToken = token;
|
||||
this.#streamingEscapeArmedUntil = now + STREAMING_ESCAPE_CANCEL_WINDOW_MS;
|
||||
this.#streamingEscapeTimer = setTimeout(() => {
|
||||
if (this.#streamingEscapeArmedToken === token && Date.now() >= this.#streamingEscapeArmedUntil) {
|
||||
this.#clearStreamingEscapeArm();
|
||||
}
|
||||
}, STREAMING_ESCAPE_CANCEL_WINDOW_MS);
|
||||
this.#streamingEscapeTimer.unref?.();
|
||||
this.ctx.showStatus("Press Esc again within 2s to cancel streaming.");
|
||||
#abortStreamingTurn(): void {
|
||||
void this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
|
||||
}
|
||||
|
||||
setupKeyHandlers(): void {
|
||||
this.ctx.editor.setActionKeys("app.interrupt", this.ctx.keybindings.getKeys("app.interrupt"));
|
||||
if (!this.#streamingEscapeSessionSubscribed && typeof this.ctx.session.subscribe === "function") {
|
||||
this.#streamingEscapeSessionSubscribed = true;
|
||||
this.ctx.session.subscribe(event => {
|
||||
if (event.type === "agent_start" || event.type === "agent_end") {
|
||||
this.#streamingEscapeTurnSentinel = undefined;
|
||||
this.#clearStreamingEscapeArm();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!this.#focusedLeftTapListenerInstalled) {
|
||||
this.#focusedLeftTapListenerInstalled = true;
|
||||
this.ctx.ui.addInputListener(data => {
|
||||
@@ -351,7 +302,7 @@ export class InputController {
|
||||
if (this.ctx.loopModeEnabled) {
|
||||
this.ctx.pauseLoop();
|
||||
if (this.ctx.session.isStreaming) {
|
||||
this.#handleStreamingEscape();
|
||||
this.#abortStreamingTurn();
|
||||
} else {
|
||||
this.ctx.cancelPendingSubmission();
|
||||
}
|
||||
@@ -402,11 +353,10 @@ export class InputController {
|
||||
this.ctx.isPythonMode = false;
|
||||
this.ctx.updateEditorBorderColor();
|
||||
} else if (this.ctx.session.isStreaming) {
|
||||
this.#handleStreamingEscape();
|
||||
this.#abortStreamingTurn();
|
||||
} else if (this.ctx.editor.getText().trim()) {
|
||||
// Esc must not destroy an in-progress draft; it only disarms a previous empty-editor Esc.
|
||||
// Esc must not destroy an in-progress draft.
|
||||
this.ctx.lastEscapeTime = 0;
|
||||
this.#clearStreamingEscapeArm();
|
||||
} else if (vocalizer.isSpeaking()) {
|
||||
// TTS buffers seconds of PCM past the streaming abort, so an Esc
|
||||
// arriving after the model stopped would otherwise fall through to
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction";
|
||||
import type { AssistantMessage, ImageContent, Message, Model, Usage, UsageReport } from "@oh-my-pi/pi-ai";
|
||||
import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
|
||||
import type {
|
||||
AutocompleteProvider,
|
||||
Component,
|
||||
EditorTheme,
|
||||
LoaderMessageColorFn,
|
||||
@@ -59,6 +60,7 @@ import { applyProviderGlobalsFromSettings } from "../config/provider-globals";
|
||||
import { isSettingsInitialized, onStatusLineSessionAccentChanged, Settings, settings } from "../config/settings";
|
||||
import { clearClaudePluginRootsCache } from "../discovery/helpers";
|
||||
import type {
|
||||
AutocompleteProviderFactory,
|
||||
ContextUsage,
|
||||
ExtensionUIContext,
|
||||
ExtensionUIDialogOptions,
|
||||
@@ -516,6 +518,10 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
collabGuest?: CollabGuestLink;
|
||||
|
||||
#pendingSlashCommands: SlashCommand[] = [];
|
||||
/** Built-in editor autocomplete provider, before extension wrapping. */
|
||||
#baseAutocompleteProvider: AutocompleteProvider | undefined;
|
||||
/** Extension-registered provider factories, applied in registration order (#4919). */
|
||||
#autocompleteProviderFactories: AutocompleteProviderFactory[] = [];
|
||||
#cleanupUnsubscribe?: () => void;
|
||||
#signalTeardown?: SessionTeardown;
|
||||
readonly #version: string;
|
||||
@@ -1094,14 +1100,49 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
// source suffix (e.g. "Review code (project)"), so pass it through verbatim.
|
||||
description: template.description,
|
||||
}));
|
||||
const autocompleteProvider = this.#inputController.createAutocompleteProvider(
|
||||
this.#baseAutocompleteProvider = this.#inputController.createAutocompleteProvider(
|
||||
[...this.#pendingSlashCommands, ...fileSlashCommands, ...promptTemplateCommands],
|
||||
basePath,
|
||||
);
|
||||
this.editor.setAutocompleteProvider(autocompleteProvider);
|
||||
this.#applyAutocompleteProvider();
|
||||
this.session.setSlashCommands(fileCommands);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the editor's autocomplete provider: the built-in provider wrapped
|
||||
* by every extension-registered factory, in registration order. A factory
|
||||
* that throws or returns a malformed provider is skipped so one broken
|
||||
* extension cannot take down core autocomplete.
|
||||
*/
|
||||
#applyAutocompleteProvider(): void {
|
||||
const base = this.#baseAutocompleteProvider;
|
||||
if (!base) return;
|
||||
let provider = base;
|
||||
for (const factory of this.#autocompleteProviderFactories) {
|
||||
try {
|
||||
const wrapped = factory(provider);
|
||||
if (
|
||||
wrapped &&
|
||||
typeof wrapped.getSuggestions === "function" &&
|
||||
typeof wrapped.applyCompletion === "function"
|
||||
) {
|
||||
provider = wrapped;
|
||||
} else {
|
||||
logger.warn("Extension autocomplete provider factory returned an invalid provider; skipping it");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn("Extension autocomplete provider factory threw; skipping it", { error: String(error) });
|
||||
}
|
||||
}
|
||||
this.editor.setAutocompleteProvider(provider);
|
||||
}
|
||||
|
||||
/** Stack extension autocomplete behavior on top of the built-in editor provider (#4919). */
|
||||
addAutocompleteProvider(factory: AutocompleteProviderFactory): void {
|
||||
this.#autocompleteProviderFactories.push(factory);
|
||||
this.#applyAutocompleteProvider();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-point the process and every cwd-derived cache at `newCwd` after the
|
||||
* active session's working directory changed (`/move` relocation or resuming
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
RpcAvailableSlashCommand,
|
||||
RpcCommand,
|
||||
RpcExtensionUIRequest,
|
||||
RpcExtensionUIResponse,
|
||||
RpcHandoffResult,
|
||||
RpcHostToolCallRequest,
|
||||
RpcHostToolCancelRequest,
|
||||
@@ -722,25 +723,50 @@ export class RpcClient {
|
||||
/**
|
||||
* Trigger OAuth login for the given provider.
|
||||
* The server will emit an `open_url` extension_ui_request for the auth URL.
|
||||
* Providers that require pasted-code completion may then emit an `input`
|
||||
* extension_ui_request; pass `onManualCodeInput` to satisfy it.
|
||||
* Resolves when login completes or rejects on failure.
|
||||
*
|
||||
* @param onOpenUrl Called when the server emits the auth URL. The host must
|
||||
* open `url` in a browser for the callback-server OAuth flow to complete.
|
||||
* When the flow's callback server hosts a `/launch` redirect, `launchUrl`
|
||||
* is a short loopback URL that 302s to `url` — hosts SHOULD surface it as
|
||||
* the truncation-safe copy target so terminal viewport clipping cannot
|
||||
* corrupt trailing OAuth query parameters (e.g. `code_challenge_method=S256`).
|
||||
* open `url` in a browser. When the flow's callback server hosts a
|
||||
* `/launch` redirect, `launchUrl` is a short loopback URL that 302s to
|
||||
* `url` — hosts SHOULD surface it as the truncation-safe copy target so
|
||||
* terminal viewport clipping cannot corrupt trailing OAuth query
|
||||
* parameters (e.g. `code_challenge_method=S256`).
|
||||
*/
|
||||
async login(
|
||||
providerId: string,
|
||||
options?: { onOpenUrl?: (url: string, instructions?: string, launchUrl?: string) => void },
|
||||
options?: {
|
||||
onOpenUrl?: (url: string, instructions?: string, launchUrl?: string) => void;
|
||||
onManualCodeInput?: (prompt: { title: string; placeholder?: string }) => string | Promise<string>;
|
||||
},
|
||||
): Promise<{ providerId: string }> {
|
||||
const { onOpenUrl } = options ?? {};
|
||||
const listener = onOpenUrl
|
||||
? (req: RpcExtensionUIRequest) => {
|
||||
if (req.method === "open_url") onOpenUrl(req.url, req.instructions, req.launchUrl);
|
||||
}
|
||||
: undefined;
|
||||
const { onManualCodeInput, onOpenUrl } = options ?? {};
|
||||
const listener =
|
||||
onOpenUrl || onManualCodeInput
|
||||
? (req: RpcExtensionUIRequest) => {
|
||||
if (req.method === "open_url") {
|
||||
onOpenUrl?.(req.url, req.instructions, req.launchUrl);
|
||||
return;
|
||||
}
|
||||
if (req.method !== "input" || !onManualCodeInput) return;
|
||||
void Promise.resolve(onManualCodeInput({ title: req.title, placeholder: req.placeholder }))
|
||||
.then(value => {
|
||||
this.#writeFrame({
|
||||
type: "extension_ui_response",
|
||||
id: req.id,
|
||||
value,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
this.#writeFrame({
|
||||
type: "extension_ui_response",
|
||||
id: req.id,
|
||||
cancelled: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
: undefined;
|
||||
if (listener) this.#extensionUiListeners.add(listener);
|
||||
try {
|
||||
const response = await this.#send({ type: "login", providerId }, 600_000);
|
||||
@@ -1006,7 +1032,10 @@ export class RpcClient {
|
||||
}
|
||||
}
|
||||
|
||||
#writeFrame(frame: RpcCommand | RpcHostToolResult | RpcHostToolUpdate, onError?: (error: Error) => void): void {
|
||||
#writeFrame(
|
||||
frame: RpcCommand | RpcExtensionUIResponse | RpcHostToolResult | RpcHostToolUpdate,
|
||||
onError?: (error: Error) => void,
|
||||
): void {
|
||||
if (!this.#process?.stdin) {
|
||||
throw new Error("Client not started");
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ export type RpcSkillCommandResult = { agentInvoked: true };
|
||||
export async function tryRunRpcSkillCommand(
|
||||
session: RpcSkillCommandSession,
|
||||
text: string,
|
||||
streamingBehavior: "steer" | "followUp" = "steer",
|
||||
): Promise<RpcSkillCommandResult | false> {
|
||||
if (!session.skillsSettings?.enableSkillCommands) return false;
|
||||
const parsed = parseSkillInvocation(text);
|
||||
@@ -95,13 +96,16 @@ export async function tryRunRpcSkillCommand(
|
||||
const skill = session.skills.find(candidate => candidate.name === parsed.name);
|
||||
if (!skill) return false;
|
||||
const built = await buildSkillPromptMessage(skill, parsed.args, "user");
|
||||
await session.promptCustomMessage({
|
||||
customType: SKILL_PROMPT_MESSAGE_TYPE,
|
||||
content: built.message,
|
||||
display: true,
|
||||
details: built.details,
|
||||
attribution: "user",
|
||||
});
|
||||
await session.promptCustomMessage(
|
||||
{
|
||||
customType: SKILL_PROMPT_MESSAGE_TYPE,
|
||||
content: built.message,
|
||||
display: true,
|
||||
details: built.details,
|
||||
attribution: "user",
|
||||
},
|
||||
{ streamingBehavior },
|
||||
);
|
||||
return { agentInvoked: true };
|
||||
}
|
||||
|
||||
@@ -755,6 +759,10 @@ export async function runRpcMode(
|
||||
return requestRpcEditor(this.pendingRequests, this.output, title, prefill, dialogOptions, editorOptions);
|
||||
}
|
||||
|
||||
addAutocompleteProvider(): void {
|
||||
// Autocomplete provider composition is not supported in RPC mode
|
||||
}
|
||||
|
||||
get theme(): Theme {
|
||||
return theme;
|
||||
}
|
||||
@@ -842,7 +850,7 @@ export async function runRpcMode(
|
||||
// =================================================================
|
||||
|
||||
case "prompt": {
|
||||
const skillResult = await tryRunRpcSkillCommand(session, command.message);
|
||||
const skillResult = await tryRunRpcSkillCommand(session, command.message, command.streamingBehavior);
|
||||
if (skillResult) {
|
||||
return success(id, "prompt", skillResult);
|
||||
}
|
||||
@@ -1198,11 +1206,9 @@ export async function runRpcMode(
|
||||
return error(id, "login", `Unknown OAuth provider: ${command.providerId}`);
|
||||
}
|
||||
const uiCtx = new RpcExtensionUIContext(pendingExtensionRequests, output);
|
||||
// Track whether onAuth has fired. Providers that use OAuthCallbackFlow
|
||||
// always call onAuth first (emit browser URL), then onManualCodeInput as
|
||||
// a fallback. Providers that require interactive input (API-key paste,
|
||||
// GitHub Enterprise URL, device-code entry) call onPrompt before onAuth.
|
||||
// We use this ordering to self-classify at runtime — no static allowlist.
|
||||
// Track whether onAuth has fired. Providers that require interactive
|
||||
// input before a browser URL cannot be satisfied headlessly; after
|
||||
// onAuth, prompt input is the pasted OAuth code/redirect URL path.
|
||||
let authEmitted = false;
|
||||
try {
|
||||
await session.modelRegistry.authStorage.login(command.providerId, {
|
||||
@@ -1220,7 +1226,7 @@ export async function runRpcMode(
|
||||
onProgress: message => {
|
||||
uiCtx.notify(message, "info");
|
||||
},
|
||||
onPrompt: () => {
|
||||
onPrompt: async prompt => {
|
||||
if (!authEmitted) {
|
||||
// onPrompt called before any auth URL — provider requires
|
||||
// interactive input that cannot be satisfied headlessly.
|
||||
@@ -1231,11 +1237,7 @@ export async function runRpcMode(
|
||||
),
|
||||
);
|
||||
}
|
||||
// onAuth has already fired — we are inside OAuthCallbackFlow's
|
||||
// manual-redirect fallback race. Returning a never-settling promise
|
||||
// lets the race block until the callback server wins; a rejection
|
||||
// would be caught as null and spin the while(true) loop.
|
||||
return new Promise<string>(() => {});
|
||||
return (await uiCtx.input(prompt.message, prompt.placeholder, { timeout: 600_000 })) ?? "";
|
||||
},
|
||||
});
|
||||
await session.modelRegistry.refresh();
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { CollabHost } from "../collab/host";
|
||||
import type { KeybindingsManager } from "../config/keybindings";
|
||||
import type { Settings } from "../config/settings";
|
||||
import type {
|
||||
AutocompleteProviderFactory,
|
||||
ExtensionUIContext,
|
||||
ExtensionUIDialogOptions,
|
||||
ExtensionUISelectItem,
|
||||
@@ -218,6 +219,8 @@ export interface InteractiveModeContext {
|
||||
// Extension UI integration
|
||||
setToolUIContext(uiContext: ExtensionUIContext, hasUI: boolean): void;
|
||||
initializeHookRunner(uiContext: ExtensionUIContext, hasUI: boolean): void;
|
||||
/** Stack extension autocomplete behavior on top of the built-in editor provider. */
|
||||
addAutocompleteProvider(factory: AutocompleteProviderFactory): void;
|
||||
setEditorComponent(
|
||||
factory: ((tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => CustomEditor) | undefined,
|
||||
): void;
|
||||
|
||||
@@ -4,7 +4,6 @@ description: Software architect for complex multi-file architectural decisions.
|
||||
tools: read, grep, glob, bash, lsp, web_search, ast_grep
|
||||
spawns: explore
|
||||
model: pi/plan, pi/slow
|
||||
thinking-level: high
|
||||
---
|
||||
|
||||
Analyze the codebase and the user's request. Produce a detailed implementation plan.
|
||||
|
||||
@@ -4,7 +4,6 @@ description: "Code review specialist for quality/security analysis"
|
||||
tools: read, grep, glob, bash, lsp, web_search, ast_grep
|
||||
spawns: explore
|
||||
model: pi/slow
|
||||
thinking-level: high
|
||||
output:
|
||||
properties:
|
||||
overall_correctness:
|
||||
|
||||
@@ -51,20 +51,20 @@ Decompose first, then {{#if taskBatch}}batch the independent leaves{{else}}issue
|
||||
|
||||
{{#if taskBatch}}
|
||||
task(
|
||||
context: "# Goal\nReview the auth diff...\n# Constraints\nRead-only...\n# Contract\nReturn findings as severity/file/line/fix...",
|
||||
context: "# Goal\nReview the auth diff…\n# Constraints\nRead-only…\n# Contract\nReturn findings as severity/file/line/fix…",
|
||||
tasks: [
|
||||
{ id: "AuthOwner", role: "Auth Storage Reviewer", assignment: "# Target\npackages/ai/src/auth-storage.ts\n# Change\nTrace credential selection...\n# Acceptance\nReturn confirmed findings only..." },
|
||||
{ id: "PromptOwner", role: "Prompt Contract Reviewer", assignment: "# Target\npackages/coding-agent/src/prompts/**\n# Change\nCheck active-tool guidance...\n# Acceptance\nReturn mismatches and exact prompt lines..." },
|
||||
{ id: "AuthOwner", role: "Auth Storage Reviewer", assignment: "# Target\npackages/ai/src/auth-storage.ts\n# Change\nTrace credential selection…\n# Acceptance\nReturn confirmed findings only…" },
|
||||
{ id: "PromptOwner", role: "Prompt Contract Reviewer", assignment: "# Target\npackages/coding-agent/src/prompts/**\n# Change\nCheck active-tool guidance…\n# Acceptance\nReturn mismatches and exact prompt lines…" },
|
||||
]
|
||||
)
|
||||
{{else}}
|
||||
task(
|
||||
role: "Auth Storage Reviewer",
|
||||
assignment: "# Target\npackages/ai/src/auth-storage.ts\n# Change\nReview the auth diff. Shared contract: read-only; return findings as severity/file/line/fix.\n# Acceptance\nReturn confirmed findings only..."
|
||||
assignment: "# Target\npackages/ai/src/auth-storage.ts\n# Change\nReview the auth diff. Shared contract: read-only; return findings as severity/file/line/fix.\n# Acceptance\nReturn confirmed findings only…"
|
||||
)
|
||||
task(
|
||||
role: "Prompt Contract Reviewer",
|
||||
assignment: "# Target\npackages/coding-agent/src/prompts/**\n# Change\nCheck active-tool guidance. Shared contract: read-only; return mismatches and exact prompt lines.\n# Acceptance\nReturn confirmed findings only..."
|
||||
assignment: "# Target\npackages/coding-agent/src/prompts/**\n# Change\nCheck active-tool guidance. Shared contract: read-only; return mismatches and exact prompt lines.\n# Acceptance\nReturn confirmed findings only…"
|
||||
)
|
||||
{{/if}}
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ Greps files using regex.
|
||||
|
||||
<instruction>
|
||||
- Rust regex (RE2-style): alternation is `foo|bar`, not GNU BRE-style `foo\|bar`; Rust word boundaries like `\bword\b` are supported. Use line anchors or post-filters instead of lookaround/backreferences.
|
||||
- `path`: SHOULD scope to a known path (e.g. `src`); pass several as a delimited list (`src; tests`). Literal colon filename + line range? Use `selector` (e.g. `{"path":"test:1-2","selector":"1-2"}`), not recursive `path:"test:1-2:1-2"`.
|
||||
- `path`: SHOULD scope to a known path (e.g. `src`); pass several as a delimited list (`src; tests`). Use `selector` only for line-number filtering, never path/root selection (`"/"` belongs in `path`).
|
||||
- Literal colon filename + line range? Use `selector` (e.g. `{"path":"test:1-2","selector":"1-2"}`), not recursive `path:"test:1-2:1-2"`.
|
||||
- Cross-line patterns detected from literal `\n` or `\\n` in `pattern`.
|
||||
</instruction>
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ Use only with ids returned by the `recall` tool. Operations:
|
||||
- `forget`: permanently delete a working memory.
|
||||
- `invalidate`: softly supersede a working or episodic memory, optionally pointing at `replacement_id`.
|
||||
|
||||
Fact ids (recall results marked `[facts]`) are read-only: inspect them with `read memory://<id>`; every edit op on a fact id returns `not_editable`.
|
||||
|
||||
Prefer `invalidate` when a memory became stale but its history may still be useful. Use `forget` only for content that should be hard-deleted.
|
||||
|
||||
**Always read the full memory before `update`.** Recall results are clipped previews (the trailing `…` marks a truncation and `full_length` reports the original size); `update` replaces content wholesale, so overwriting the preview would delete the unseen tail. Fetch the row first with `read memory://<id>`, then pass the merged content in `content`.
|
||||
|
||||
@@ -1186,6 +1186,7 @@ const noOpUIContext: ExtensionUIContext = {
|
||||
pasteToEditor: () => {},
|
||||
getEditorText: () => "",
|
||||
editor: async () => undefined,
|
||||
addAutocompleteProvider: () => {},
|
||||
get theme() {
|
||||
return theme;
|
||||
},
|
||||
@@ -8338,11 +8339,10 @@ export class AgentSession {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a user message to the agent.
|
||||
* When deliverAs is set, queue the message instead of starting a new turn.
|
||||
* Send a user message through the prompt flow.
|
||||
*
|
||||
* @param content User message content (string or content array)
|
||||
* @param options.deliverAs Delivery mode: "steer" or "followUp"
|
||||
* Omitted `deliverAs` starts a turn when idle and queues as a steer while streaming.
|
||||
* Explicit `deliverAs` queues without starting a turn in either state.
|
||||
*/
|
||||
async sendUserMessage(
|
||||
content: string | (TextContent | ImageContent)[],
|
||||
@@ -8377,10 +8377,13 @@ export class AgentSession {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use prompt() with expandPromptTemplates: false to skip command handling and template expansion
|
||||
// Use prompt() with expandPromptTemplates: false to skip command handling and template expansion.
|
||||
// `streamingBehavior: "steer"` preserves prompt-flow side effects during streaming while
|
||||
// covering the narrow race where a stream starts before prompt() acquires the turn.
|
||||
await this.prompt(text, {
|
||||
expandPromptTemplates: false,
|
||||
images,
|
||||
streamingBehavior: "steer",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ const searchSchema = type({
|
||||
'file, directory, glob, internal URL, or "<file>:<lines>" selector to search; pass several as a semicolon-delimited list ("src; tests"). Omitted -> searches the workspace root (".")',
|
||||
),
|
||||
"selector?": type("string").describe(
|
||||
'line selector without a leading colon (e.g. "50-100", "50+10", "50-100,200-300"); keeps `path` literal when filenames contain colons',
|
||||
'line selector applied to every searched file (e.g. "50-100", "50+10", "50-100,200-300"); never a path like "/"',
|
||||
),
|
||||
"case?": type("boolean").describe("case-sensitive search"),
|
||||
"gitignore?": type("boolean").describe("respect gitignore"),
|
||||
@@ -126,6 +126,7 @@ interface GrepPathSpec {
|
||||
clean: string;
|
||||
literalFilesystemMatch?: boolean;
|
||||
ranges?: [LineRange, ...LineRange[]];
|
||||
rangeSource?: "explicit" | "path";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,11 +159,11 @@ async function parsePathSpecs(
|
||||
cwd: string,
|
||||
explicitSelector?: string,
|
||||
): Promise<GrepPathSpec[]> {
|
||||
const explicitRanges =
|
||||
explicitSelector === undefined || explicitSelector.length === 0 ? undefined : parseLineRanges(explicitSelector);
|
||||
if (explicitSelector !== undefined && !explicitRanges) {
|
||||
const normalizedSelector = explicitSelector?.trim() || undefined;
|
||||
const explicitRanges = normalizedSelector === undefined ? undefined : parseLineRanges(normalizedSelector);
|
||||
if (normalizedSelector !== undefined && !explicitRanges) {
|
||||
throw new ToolError(
|
||||
`selector "${explicitSelector}" is invalid — use line ranges like "50-100", "50+10", or "50-100,200-300" without a leading colon`,
|
||||
`selector "${normalizedSelector}" is invalid — use line ranges like "50-100", "50+10", or "50-100,200-300" without a leading colon`,
|
||||
);
|
||||
}
|
||||
const specs: GrepPathSpec[] = [];
|
||||
@@ -182,6 +183,7 @@ async function parsePathSpecs(
|
||||
clean: literalMatch && !rawPathHasScheme ? resolveReadPath(entry, cwd) : entry,
|
||||
literalFilesystemMatch: literalMatch,
|
||||
ranges: explicitRanges,
|
||||
rangeSource: "explicit",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -210,6 +212,7 @@ async function parsePathSpecs(
|
||||
const literalFilesystemMatch = strictSplit.sel !== undefined && split.sel === undefined;
|
||||
let clean = literalFilesystemMatch ? resolveReadPath(entry, cwd) : entry;
|
||||
let ranges: [LineRange, ...LineRange[]] | undefined;
|
||||
let rangeSource: "path" | undefined;
|
||||
if (!literalFilesystemMatch && split.sel) {
|
||||
const parsed = parseLineRanges(split.sel);
|
||||
if (!parsed) {
|
||||
@@ -222,8 +225,15 @@ async function parsePathSpecs(
|
||||
}
|
||||
clean = split.path;
|
||||
ranges = parsed;
|
||||
rangeSource = "path";
|
||||
}
|
||||
specs.push({ original: entry, clean, literalFilesystemMatch, ranges });
|
||||
specs.push({
|
||||
original: entry,
|
||||
clean,
|
||||
literalFilesystemMatch,
|
||||
ranges,
|
||||
rangeSource: ranges ? rangeSource : undefined,
|
||||
});
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
@@ -400,6 +410,27 @@ function lineAllowed(lineNumber: number, ranges: readonly LineRange[] | undefine
|
||||
return !ranges || isLineInRanges(lineNumber, ranges);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-file native fetch budget that guarantees the JS range filter can still
|
||||
* surface `perFileKeep` in-range hits. Matches arrive one entry per matched
|
||||
* line in line order, so a bounded range's hits all sit within the first
|
||||
* `endLine` entries, and an open-ended range starting at S is preceded by at
|
||||
* most S-1 out-of-range entries — S-1+perFileKeep entries cover the kept
|
||||
* window or exhaust the file. Clamped to the native file-size ceiling (a
|
||||
* ≤4 MiB file cannot have more matched lines than bytes), which also keeps
|
||||
* the scaled global budget inside the native layer's u32 bounds.
|
||||
*/
|
||||
function lineRangeFetchCap(pathSpecs: readonly GrepPathSpec[], perFileKeep: number): number {
|
||||
let cap = 0;
|
||||
for (const spec of pathSpecs) {
|
||||
if (!spec.ranges) continue;
|
||||
for (const range of spec.ranges) {
|
||||
cap = Math.max(cap, range.endLine ?? range.startLine - 1 + perFileKeep);
|
||||
}
|
||||
}
|
||||
return Math.min(cap, NATIVE_GREP_MAX_FILE_BYTES);
|
||||
}
|
||||
|
||||
/** Binary search for the index of the line containing byte `offset`. */
|
||||
function findLineIndex(starts: readonly number[], offset: number): number {
|
||||
if (starts.length === 0) return -1;
|
||||
@@ -971,6 +1002,7 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
|
||||
const searchablePaths = internalResolution.paths;
|
||||
const { virtualResources, virtualPathSet, virtualInputIndexes } = internalResolution;
|
||||
const rangesByAbsPath = new Map<string, LineRange[]>();
|
||||
const globalRanges = pathSpecs.find(spec => spec.rangeSource === "explicit")?.ranges;
|
||||
|
||||
if (
|
||||
archiveUnreadable.length > 0 &&
|
||||
@@ -1030,6 +1062,7 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
|
||||
for (let idx = 0; idx < pathSpecs.length; idx++) {
|
||||
const spec = pathSpecs[idx];
|
||||
if (!spec.ranges) continue;
|
||||
if (spec.rangeSource === "explicit") continue;
|
||||
if (virtualInputIndexes.has(idx)) continue;
|
||||
const resolved = internalResolution.resolvedPathsByInput[idx];
|
||||
if (!resolved) continue;
|
||||
@@ -1095,6 +1128,18 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
|
||||
Boolean(multiTargets) ||
|
||||
(virtualResources.length > 0 && (virtualResources.length > 1 || searchablePaths.length > 0));
|
||||
const perFileMatchCap = isMultiScope ? MULTI_FILE_PER_FILE_MATCHES : SINGLE_FILE_MATCHES;
|
||||
// Range filtering happens in JS after the native fetch, so out-of-range
|
||||
// matches consume fetch budget. Widen the per-file budget just enough
|
||||
// that filtering can still yield `perFileMatchCap` in-range hits, and
|
||||
// scale the global safety ceiling by the same amplification so ranged
|
||||
// searches keep the baseline file coverage while staying finite.
|
||||
const hasLineRangeFilters = pathSpecs.some(spec => spec.ranges);
|
||||
const nativeMaxCountPerFile = hasLineRangeFilters
|
||||
? Math.max(perFileMatchCap + 1, lineRangeFetchCap(pathSpecs, perFileMatchCap + 1))
|
||||
: perFileMatchCap + 1;
|
||||
const nativeMaxCount = hasLineRangeFilters
|
||||
? Math.ceil(INTERNAL_TOTAL_CAP / (perFileMatchCap + 1)) * nativeMaxCountPerFile
|
||||
: INTERNAL_TOTAL_CAP;
|
||||
|
||||
// Run grep
|
||||
let result: GrepResult = {
|
||||
@@ -1129,12 +1174,12 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
|
||||
multiline: effectiveMultiline,
|
||||
hidden: true,
|
||||
gitignore: useGitignore,
|
||||
maxCount: INTERNAL_TOTAL_CAP,
|
||||
maxCount: nativeMaxCount,
|
||||
contextBefore: normalizedContextBefore,
|
||||
contextAfter: normalizedContextAfter,
|
||||
maxColumns: DEFAULT_MAX_COLUMN,
|
||||
mode: effectiveOutputMode,
|
||||
maxCountPerFile: perFileMatchCap + 1,
|
||||
maxCountPerFile: nativeMaxCountPerFile,
|
||||
signal,
|
||||
timeoutMs: SEARCH_GREP_TIMEOUT_MS,
|
||||
},
|
||||
@@ -1176,12 +1221,12 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
|
||||
multiline: effectiveMultiline,
|
||||
hidden: true,
|
||||
gitignore: useGitignore,
|
||||
maxCount: INTERNAL_TOTAL_CAP,
|
||||
maxCount: nativeMaxCount,
|
||||
contextBefore: normalizedContextBefore,
|
||||
contextAfter: normalizedContextAfter,
|
||||
maxColumns: DEFAULT_MAX_COLUMN,
|
||||
mode: effectiveOutputMode,
|
||||
maxCountPerFile: perFileMatchCap + 1,
|
||||
maxCountPerFile: nativeMaxCountPerFile,
|
||||
signal,
|
||||
timeoutMs: SEARCH_GREP_TIMEOUT_MS,
|
||||
},
|
||||
@@ -1222,12 +1267,12 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
result = mergeGrepResults(result, virtualResult, INTERNAL_TOTAL_CAP);
|
||||
if (rangesByAbsPath.size > 0) {
|
||||
result = mergeGrepResults(result, virtualResult, nativeMaxCount);
|
||||
if (rangesByAbsPath.size > 0 || globalRanges) {
|
||||
const filteredMatches: GrepMatch[] = [];
|
||||
for (const match of result.matches) {
|
||||
const abs = matchAbsolutePath(match.path, searchPath);
|
||||
const ranges = rangesByAbsPath.get(abs);
|
||||
const ranges = rangesByAbsPath.get(abs) ?? globalRanges;
|
||||
if (!ranges) {
|
||||
// Path has no line-range constraint (e.g. a peer entry without `:N-M`).
|
||||
filteredMatches.push(match);
|
||||
|
||||
@@ -1276,7 +1276,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
|
||||
const xaiCreds = await resolveXAIHttpCredentials(ctx.modelRegistry, resolvedModel);
|
||||
if (!xaiCreds) {
|
||||
throw new Error(
|
||||
"No xAI credentials. Run /login → xAI Grok OAuth (SuperGrok Subscription) or set XAI_API_KEY.",
|
||||
"No xAI credentials. Run /login → xAI Grok OAuth (SuperGrok or X Premium+) or set XAI_API_KEY.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,9 @@ export class MemoryEditTool implements AgentTool<typeof memoryEditSchema> {
|
||||
const text =
|
||||
result.status === "not_found"
|
||||
? `Memory ${params.id} was not found${location}.`
|
||||
: `Memory ${params.id} ${result.status}${location}.`;
|
||||
: result.status === "not_editable"
|
||||
? `Memory ${params.id} is a read-only fact${location}; it cannot be edited. Read it with memory://${params.id}.`
|
||||
: `Memory ${params.id} ${result.status}${location}.`;
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
details: result,
|
||||
|
||||
@@ -2118,13 +2118,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
_toolContext?: AgentToolContext,
|
||||
): Promise<AgentToolResult<ReadToolDetails>> {
|
||||
let { path: readPath } = params;
|
||||
let explicitSelector = params.selector?.trim();
|
||||
let explicitSelector = params.selector?.trim() || undefined;
|
||||
let explicitParsedSelector = explicitSelector === undefined ? undefined : parseSel(explicitSelector);
|
||||
if (
|
||||
params.selector !== undefined &&
|
||||
(explicitSelector === undefined || explicitSelector.length === 0 || explicitParsedSelector?.kind === "none")
|
||||
) {
|
||||
throw invalidSelector(params.selector);
|
||||
if (explicitSelector !== undefined && explicitParsedSelector?.kind === "none") {
|
||||
throw invalidSelector(explicitSelector);
|
||||
}
|
||||
if (readPath.startsWith("file://")) {
|
||||
readPath = expandPath(readPath);
|
||||
@@ -3314,6 +3311,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
interface ReadRenderArgs {
|
||||
path?: unknown;
|
||||
file_path?: unknown;
|
||||
selector?: unknown;
|
||||
sel?: string;
|
||||
// Legacy fields from old schema — tolerated for in-flight tool calls during transition
|
||||
offset?: number;
|
||||
@@ -3378,10 +3376,20 @@ function formatReadPathLink(
|
||||
|
||||
export const readToolRenderer = {
|
||||
renderCall(args: ReadRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
||||
const rawPath =
|
||||
const baseRawPath =
|
||||
typeof args.file_path === "string" ? args.file_path : typeof args.path === "string" ? args.path : "";
|
||||
if (isReadableUrlPath(rawPath)) {
|
||||
return renderReadUrlCall({ path: rawPath, raw: args.raw }, _options, uiTheme);
|
||||
const explicitSelector =
|
||||
typeof args.selector === "string"
|
||||
? args.selector.trim().replace(/^:+/, "")
|
||||
: args.sel?.trim().replace(/^:+/, "");
|
||||
const rawPath =
|
||||
explicitSelector && explicitSelector.length > 0 ? `${baseRawPath}:${explicitSelector}` : baseRawPath;
|
||||
if (isReadableUrlPath(baseRawPath)) {
|
||||
return renderReadUrlCall(
|
||||
{ path: rawPath, raw: args.raw || explicitSelector?.toLowerCase() === "raw" },
|
||||
_options,
|
||||
uiTheme,
|
||||
);
|
||||
}
|
||||
|
||||
const offset = args.offset;
|
||||
@@ -3405,9 +3413,9 @@ export const readToolRenderer = {
|
||||
args?: ReadRenderArgs,
|
||||
): Component {
|
||||
const urlDetails = result.details as ReadUrlToolDetails | undefined;
|
||||
const rawPathForKind =
|
||||
const baseRawPathForKind =
|
||||
typeof args?.file_path === "string" ? args.file_path : typeof args?.path === "string" ? args.path : "";
|
||||
if (urlDetails?.kind === "url" || isReadableUrlPath(rawPathForKind)) {
|
||||
if (urlDetails?.kind === "url" || isReadableUrlPath(baseRawPathForKind)) {
|
||||
return renderReadUrlResult(
|
||||
result as {
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
@@ -3422,8 +3430,14 @@ export const readToolRenderer = {
|
||||
if (result.isError) {
|
||||
const rawErrorText = result.content?.find(c => c.type === "text")?.text ?? "";
|
||||
const errorText = (rawErrorText || "Unknown error").replace(/^Error:\s*/, "");
|
||||
const rawPath =
|
||||
const baseRawPath =
|
||||
typeof args?.file_path === "string" ? args.file_path : typeof args?.path === "string" ? args.path : "";
|
||||
const explicitSelector =
|
||||
typeof args?.selector === "string"
|
||||
? args.selector.trim().replace(/^:+/, "")
|
||||
: args?.sel?.trim().replace(/^:+/, "");
|
||||
const rawPath =
|
||||
explicitSelector && explicitSelector.length > 0 ? `${baseRawPath}:${explicitSelector}` : baseRawPath;
|
||||
const filePath =
|
||||
formatReadPathLink(rawPath, { offset: args?.offset, sourcePath: readSourceFsPath(result.details) }) ||
|
||||
shortenPath(rawPath);
|
||||
@@ -3450,8 +3464,14 @@ export const readToolRenderer = {
|
||||
// echo next to the styled warning line below.
|
||||
const contentText = details?.displayContent?.text ?? stripOutputNotice(rawText, details?.meta);
|
||||
const imageContent = result.content?.find(c => c.type === "image");
|
||||
const rawPath =
|
||||
const baseRawPath =
|
||||
typeof args?.file_path === "string" ? args.file_path : typeof args?.path === "string" ? args.path : "";
|
||||
const explicitSelector =
|
||||
typeof args?.selector === "string"
|
||||
? args.selector.trim().replace(/^:+/, "")
|
||||
: args?.sel?.trim().replace(/^:+/, "");
|
||||
const rawPath =
|
||||
explicitSelector && explicitSelector.length > 0 ? `${baseRawPath}:${explicitSelector}` : baseRawPath;
|
||||
const renderPath = splitReadRenderPath(rawPath);
|
||||
const lang = getLanguageFromPath(renderPath.path);
|
||||
|
||||
|
||||
@@ -32,6 +32,15 @@ import { sshToolRenderer } from "./ssh";
|
||||
import { todoToolRenderer } from "./todo";
|
||||
import { writeToolRenderer } from "./write";
|
||||
|
||||
/**
|
||||
* Per-renderer opt-in for a full viewport replay when the first result
|
||||
* replaces a painted pending-call render. A predicate receives the painted
|
||||
* call args and render options so the repaint stays scoped to the pending
|
||||
* shapes that actually re-anchor (an over-eager replay wipes native
|
||||
* scrollback on direct terminals).
|
||||
*/
|
||||
export type FirstResultViewportRepaint = boolean | ((args: unknown, options: RenderResultOptions) => boolean);
|
||||
|
||||
export type ToolRenderer = {
|
||||
renderCall: (args: unknown, options: RenderResultOptions, theme: Theme) => Component;
|
||||
renderResult: (
|
||||
@@ -55,12 +64,11 @@ export type ToolRenderer = {
|
||||
*/
|
||||
animatedPartialResult?: boolean | ((args: unknown) => boolean);
|
||||
/**
|
||||
* Whether replacing a streamed pending placeholder with the first result
|
||||
* requires a full viewport repaint. Use for merged renderers whose pending
|
||||
* streamed args may have committed placeholder rows that the result render
|
||||
* re-anchors instead of preserving.
|
||||
* Whether replacing a pending call render with the first result requires a
|
||||
* full viewport repaint. Use for merged renderers whose pending rows can be
|
||||
* re-anchored instead of preserved by the result render.
|
||||
*/
|
||||
forceFirstResultViewportRepaint?: boolean;
|
||||
forceFirstResultViewportRepaint?: FirstResultViewportRepaint;
|
||||
/**
|
||||
* Whether settling a provisional partial result into the final render requires
|
||||
* a full viewport repaint. Use when the result renderer changes chrome or
|
||||
|
||||
@@ -244,6 +244,13 @@ interface SshRenderArgs {
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/** Whether the painted call args still carry the streamed raw-JSON buffer —
|
||||
* the shape that renders the `⏳ SSH: […]` / `$ …` placeholder. */
|
||||
function hasStreamedRenderArgs(args: unknown): boolean {
|
||||
if (args == null || typeof args !== "object" || !("__partialJson" in args)) return false;
|
||||
return typeof args.__partialJson === "string";
|
||||
}
|
||||
|
||||
interface SshRenderContext {
|
||||
/** Visual lines for truncated output (pre-computed by tool-execution) */
|
||||
visualLines?: string[];
|
||||
@@ -388,9 +395,9 @@ export const sshToolRenderer = {
|
||||
mergeCallAndResult: true,
|
||||
// Streamed args can initially render the SSH placeholder (`⏳ SSH: […]` /
|
||||
// `$ …`), then the first partial result inserts the `Output` section and
|
||||
// re-anchors the frame. Force a full repaint at that seam so placeholder rows
|
||||
// do not survive in viewport/native scrollback.
|
||||
forceFirstResultViewportRepaint: true,
|
||||
// re-anchors the frame. Force a full repaint only at that streamed-placeholder
|
||||
// seam so placeholder rows do not survive in viewport/native scrollback.
|
||||
forceFirstResultViewportRepaint: hasStreamedRenderArgs,
|
||||
// The provisional pending-result frame settles into the final `⇄ SSH: [host]`
|
||||
// frame, so clear/replay the viewport at that topology flip too.
|
||||
forceResultViewportRepaintOnSettle: true,
|
||||
|
||||
@@ -103,7 +103,7 @@ async function synthesizeXai(
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "No xAI credentials. Run /login → xAI Grok OAuth (SuperGrok Subscription) or set XAI_API_KEY.",
|
||||
text: "No xAI credentials. Run /login → xAI Grok OAuth (SuperGrok or X Premium+) or set XAI_API_KEY.",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -985,6 +985,24 @@ function countLines(text: string): number {
|
||||
return text.split("\n").length;
|
||||
}
|
||||
|
||||
/** Bounded newline scan: whether `text` spans more than `maxLines` lines.
|
||||
* Runs on every live compose (the repaint predicate below), so it must not
|
||||
* materialize the split the way `countLines` does. */
|
||||
function exceedsLineCount(text: string, maxLines: number): boolean {
|
||||
if (!text) return false;
|
||||
let lines = 1;
|
||||
for (let index = text.indexOf("\n"); index !== -1; index = text.indexOf("\n", index + 1)) {
|
||||
if (++lines > maxLines) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function writeContentOf(args: unknown): string {
|
||||
if (args == null || typeof args !== "object" || !("content" in args)) return "";
|
||||
const content = args.content;
|
||||
return typeof content === "string" ? content : "";
|
||||
}
|
||||
|
||||
function formatLineCountSuffix(lineCount: number, uiTheme: Theme): string {
|
||||
if (lineCount <= 0) return "";
|
||||
return uiTheme.fg("dim", ` · ${lineCount} line${lineCount === 1 ? "" : "s"}`);
|
||||
@@ -1218,4 +1236,12 @@ export const writeToolRenderer = {
|
||||
});
|
||||
},
|
||||
mergeCallAndResult: true,
|
||||
// The collapsed pending preview follows the streaming edge with a tail
|
||||
// window once the content outgrows it (`… (N earlier lines)` + last rows);
|
||||
// the first partial result re-anchors the frame to the top of the file, so
|
||||
// tail rows already committed to viewport/native scrollback would survive
|
||||
// as stale content above the new frame without a full replay. Expanded and
|
||||
// short previews stay top-anchored and skip the (scrollback-wiping) reset.
|
||||
forceFirstResultViewportRepaint: (args: unknown, options: RenderResultOptions) =>
|
||||
!options.expanded && exceedsLineCount(writeContentOf(args), WRITE_STREAMING_PREVIEW_LINES),
|
||||
};
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
||||
|
||||
// Single-entry memo for the proseOnly formatting path. During a streaming tick
|
||||
// the same growing thinking text is formatted up to three times (reveal count,
|
||||
// reveal slice, component render); this collapses them to one computation. The
|
||||
// `proseOnly === false` branch is a passthrough and never consults the cache, so
|
||||
// the key can be the text alone. A single entry is enough for the common case of
|
||||
// one active thinking block and never regresses (a miss recomputes exactly as
|
||||
// before).
|
||||
let formatCacheKey = "";
|
||||
let formatCacheValue = "";
|
||||
// Single-slot-per-mode memo for formatThinkingForDisplay. During a streaming
|
||||
// tick the same growing thinking text is formatted up to three times (reveal
|
||||
// count, reveal slice, component render); this collapses them to one
|
||||
// computation. Prose and raw modes produce different output for the same text,
|
||||
// so each mode keeps its own slot. One entry per mode is enough for the common
|
||||
// case of one active thinking block and never regresses (a miss recomputes
|
||||
// exactly as before).
|
||||
let proseCacheKey = "";
|
||||
let proseCacheValue = "";
|
||||
let rawCacheKey = "";
|
||||
let rawCacheValue = "";
|
||||
|
||||
export function canonicalizeMessage(text: string | null | undefined): string {
|
||||
if (!text) return "";
|
||||
@@ -22,9 +24,35 @@ export function canonicalizeMessage(text: string | null | undefined): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
// gpt-5.x reasoning summaries pad every summary part with an empty HTML
|
||||
// comment (`**Headline**\n\n<!-- -->`), streamed as a `<!--` delta followed by
|
||||
// ` -->`. Comments with actual content are left untouched.
|
||||
const EMPTY_COMMENT_RE = /^<!--\s*-->$/;
|
||||
const OPEN_COMMENT_RE = /^<!--\s*$/;
|
||||
|
||||
/**
|
||||
* Whether `line` is reasoning-summary comment noise: an empty HTML comment,
|
||||
* or its still-unterminated `<!--` prefix on the last line while streaming.
|
||||
*/
|
||||
function isCommentNoise(line: string, isLastLine: boolean): boolean {
|
||||
const trimmed = line.trim();
|
||||
return EMPTY_COMMENT_RE.test(trimmed) || (isLastLine && OPEN_COMMENT_RE.test(trimmed));
|
||||
}
|
||||
|
||||
/**
|
||||
* Thinking text prepared for display. Both modes drop empty `<!-- -->`
|
||||
* sentinel lines outside code fences (see {@link isCommentNoise}); prose-only
|
||||
* mode additionally elides fenced code down to a trailing ellipsis.
|
||||
*/
|
||||
export function formatThinkingForDisplay(text: string, proseOnly: boolean): string {
|
||||
if (!proseOnly || !text) return text;
|
||||
if (text === formatCacheKey) return formatCacheValue;
|
||||
if (!text) return text;
|
||||
const hasComment = text.includes("<!--");
|
||||
if (proseOnly) {
|
||||
if (text === proseCacheKey) return proseCacheValue;
|
||||
} else {
|
||||
if (!hasComment) return text;
|
||||
if (text === rawCacheKey) return rawCacheValue;
|
||||
}
|
||||
|
||||
const lines = text.split("\n");
|
||||
const resultLines: string[] = [];
|
||||
@@ -56,22 +84,31 @@ export function formatThinkingForDisplay(text: string, proseOnly: boolean): stri
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]!;
|
||||
const open = FENCE.exec(line);
|
||||
|
||||
if (inFence) {
|
||||
const close = FENCE.exec(line);
|
||||
// A closing fence is the same char, at least as long, with nothing else on the line.
|
||||
if (
|
||||
open &&
|
||||
open[2]![0] === fenceChar &&
|
||||
open[2]!.length >= fenceLen &&
|
||||
line.slice(open[1]!.length + open[2]!.length).trim() === ""
|
||||
close &&
|
||||
close[2]![0] === fenceChar &&
|
||||
close[2]!.length >= fenceLen &&
|
||||
line.slice(close[1]!.length + close[2]!.length).trim() === ""
|
||||
) {
|
||||
inFence = false;
|
||||
fenceChar = "";
|
||||
fenceLen = 0;
|
||||
}
|
||||
// We skip all internal lines of a code fence.
|
||||
} else if (open) {
|
||||
// Prose mode skips all fence lines; raw mode keeps them verbatim
|
||||
// (comment markers inside fences are code, not noise).
|
||||
if (!proseOnly) resultLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Drop the whole line so `**Headline**\n\n<!-- -->` leaves no blank tail.
|
||||
if (hasComment && isCommentNoise(line, i === lines.length - 1)) continue;
|
||||
|
||||
const open = FENCE.exec(line);
|
||||
if (open) {
|
||||
const marker = open[2]!;
|
||||
const ch = marker[0]!;
|
||||
// A backtick fence's info string may not contain a backtick.
|
||||
@@ -79,18 +116,25 @@ export function formatThinkingForDisplay(text: string, proseOnly: boolean): stri
|
||||
inFence = true;
|
||||
fenceChar = ch;
|
||||
fenceLen = marker.length;
|
||||
appendEllipsis();
|
||||
} else {
|
||||
resultLines.push(line);
|
||||
if (proseOnly) {
|
||||
appendEllipsis();
|
||||
} else {
|
||||
resultLines.push(line);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
resultLines.push(line);
|
||||
}
|
||||
resultLines.push(line);
|
||||
}
|
||||
|
||||
const formatted = resultLines.join("\n");
|
||||
formatCacheKey = text;
|
||||
formatCacheValue = formatted;
|
||||
if (proseOnly) {
|
||||
proseCacheKey = text;
|
||||
proseCacheValue = formatted;
|
||||
} else {
|
||||
rawCacheKey = text;
|
||||
rawCacheValue = formatted;
|
||||
}
|
||||
return formatted;
|
||||
}
|
||||
|
||||
@@ -99,9 +143,11 @@ export function hasDisplayableThinking(
|
||||
text: string | null | undefined,
|
||||
formattedText: string | null | undefined,
|
||||
): boolean {
|
||||
if (!text) return false;
|
||||
if (!formattedText) return false;
|
||||
return formattedText.length > 0 && canonicalizeMessage(text).length > 0;
|
||||
if (!text || !formattedText) return false;
|
||||
// Visibility keys off the formatted text: a block whose raw text is only
|
||||
// comment noise (`<!-- -->\n`) formats to whitespace and stays hidden. The
|
||||
// raw canonicalize check still hides dot/ellipsis-only placeholder blocks.
|
||||
return formattedText.trim().length > 0 && canonicalizeMessage(text).length > 0;
|
||||
}
|
||||
|
||||
/** Whether an assistant message contains thinking content the TUI can reveal. */
|
||||
|
||||
@@ -124,6 +124,7 @@ class FakeAgentSession {
|
||||
}
|
||||
promptCalls: string[] = [];
|
||||
customMessages: Array<{ customType: string; content: string; details?: unknown }> = [];
|
||||
customMessageOptions: Array<{ streamingBehavior?: "steer" | "followUp"; queueChipText?: string } | undefined> = [];
|
||||
skillsSettings = { enableSkillCommands: true };
|
||||
skills: Array<{ name: string; description: string; filePath: string; baseDir: string; source: string }> = [];
|
||||
planModeState: PlanModeState | undefined;
|
||||
@@ -235,8 +236,12 @@ class FakeAgentSession {
|
||||
this.isStreaming = false;
|
||||
}
|
||||
|
||||
async promptCustomMessage(message: { customType: string; content: string; details?: unknown }): Promise<void> {
|
||||
async promptCustomMessage(
|
||||
message: { customType: string; content: string; details?: unknown },
|
||||
options?: { streamingBehavior?: "steer" | "followUp"; queueChipText?: string },
|
||||
): Promise<void> {
|
||||
this.customMessages.push(message);
|
||||
this.customMessageOptions.push(options);
|
||||
this.isStreaming = true;
|
||||
const assistantMessage = makeAssistantMessage("skill pong");
|
||||
for (const listener of this.#listeners) {
|
||||
@@ -1013,6 +1018,115 @@ describe("ACP agent", () => {
|
||||
await Bun.sleep(0);
|
||||
});
|
||||
|
||||
it("delivers the final visible answer when agent_end overtakes the assistant message_end (#4902)", async () => {
|
||||
const harness = await createHarness();
|
||||
const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] });
|
||||
const session = harness.findSession(created.sessionId);
|
||||
if (!session) throw new Error("session not registered");
|
||||
|
||||
// Live turn as observed through the prompt subscription when the
|
||||
// fire-and-forget assistant message_end handler loses the race against
|
||||
// the agent_end flush: thinking streams, then the turn ends. No
|
||||
// text_delta and no message_end ever reach this subscriber — the final
|
||||
// text exists only on the agent_end payload.
|
||||
const assistantMessage = makeAssistantMessage("Final visible answer.", "Considering the greeting.");
|
||||
session.prompt = async (text: string): Promise<boolean> => {
|
||||
session.promptCalls.push(text);
|
||||
session.isStreaming = true;
|
||||
for (const listener of session.listeners()) {
|
||||
listener({
|
||||
type: "message_update",
|
||||
message: assistantMessage,
|
||||
assistantMessageEvent: { type: "thinking_delta", delta: "Considering the greeting." },
|
||||
} as AgentSessionEvent);
|
||||
}
|
||||
session.sessionManager.appendMessage(assistantMessage);
|
||||
for (const listener of session.listeners()) {
|
||||
listener({ type: "agent_end", messages: [assistantMessage] } as AgentSessionEvent);
|
||||
}
|
||||
session.isStreaming = false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const response = await harness.agent.prompt({
|
||||
sessionId: created.sessionId,
|
||||
prompt: [{ type: "text", text: "Say hello" }],
|
||||
});
|
||||
expectAcpStructure(zPromptResponse, response);
|
||||
expect(response.stopReason).toBe("end_turn");
|
||||
|
||||
const chunks = harness.updates.filter(update => update.sessionId === created.sessionId);
|
||||
const thoughtChunks = chunks.filter(update => update.update.sessionUpdate === "agent_thought_chunk");
|
||||
const messageChunks = chunks.filter(update => update.update.sessionUpdate === "agent_message_chunk");
|
||||
expect(thoughtChunks).toHaveLength(1);
|
||||
// The visible answer must reach the client exactly once even though the
|
||||
// assistant message_end never arrived on this subscription.
|
||||
expect(messageChunks).toHaveLength(1);
|
||||
expect(messageChunks[0]?.update).toEqual(
|
||||
expect.objectContaining({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "Final visible answer." },
|
||||
}),
|
||||
);
|
||||
// Flushed answer belongs to the same live message as the thought chunk.
|
||||
expect(getChunkMessageId(messageChunks[0]!)).toBe(getChunkMessageId(thoughtChunks[0]!)!);
|
||||
expectAcpNotifications(harness.updates);
|
||||
|
||||
harness.abortController.abort();
|
||||
await Bun.sleep(0);
|
||||
});
|
||||
|
||||
it("does not duplicate the final answer when the assistant message_end arrives before agent_end", async () => {
|
||||
// Companion to the #4902 regression: when message_end IS delivered, its
|
||||
// fallback emission wins and the agent_end flush must stay silent.
|
||||
const harness = await createHarness();
|
||||
const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] });
|
||||
const session = harness.findSession(created.sessionId);
|
||||
if (!session) throw new Error("session not registered");
|
||||
|
||||
const assistantMessage = makeAssistantMessage("Composed offline.", "quiet planning");
|
||||
session.prompt = async (text: string): Promise<boolean> => {
|
||||
session.promptCalls.push(text);
|
||||
session.isStreaming = true;
|
||||
for (const listener of session.listeners()) {
|
||||
listener({
|
||||
type: "message_update",
|
||||
message: assistantMessage,
|
||||
assistantMessageEvent: { type: "thinking_delta", delta: "quiet planning" },
|
||||
} as AgentSessionEvent);
|
||||
}
|
||||
for (const listener of session.listeners()) {
|
||||
listener({ type: "message_end", message: assistantMessage } as AgentSessionEvent);
|
||||
}
|
||||
session.sessionManager.appendMessage(assistantMessage);
|
||||
for (const listener of session.listeners()) {
|
||||
listener({ type: "agent_end", messages: [assistantMessage] } as AgentSessionEvent);
|
||||
}
|
||||
session.isStreaming = false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const response = await harness.agent.prompt({
|
||||
sessionId: created.sessionId,
|
||||
prompt: [{ type: "text", text: "Say hello" }],
|
||||
});
|
||||
expectAcpStructure(zPromptResponse, response);
|
||||
|
||||
const messageChunks = harness.updates.filter(
|
||||
update => update.sessionId === created.sessionId && update.update.sessionUpdate === "agent_message_chunk",
|
||||
);
|
||||
expect(messageChunks).toHaveLength(1);
|
||||
expect(messageChunks[0]?.update).toEqual(
|
||||
expect.objectContaining({
|
||||
content: { type: "text", text: "Composed offline." },
|
||||
}),
|
||||
);
|
||||
expectAcpNotifications(harness.updates);
|
||||
|
||||
harness.abortController.abort();
|
||||
await Bun.sleep(0);
|
||||
});
|
||||
|
||||
it("replays assistant tool calls and matching results without duplicating the start", async () => {
|
||||
const harness = await createHarness();
|
||||
const stored = new FakeAgentSession(harness.cwdA);
|
||||
@@ -1443,6 +1557,7 @@ describe("ACP agent", () => {
|
||||
expect(customMessage.content).toContain(`[Skill directory: ${skillDir}]`);
|
||||
expect(customMessage.content).toMatch(/[Rr]esolve any relative paths/);
|
||||
expect(customMessage.content).toContain("User: extra context");
|
||||
expect(session.customMessageOptions[0]).toEqual({ streamingBehavior: "steer" });
|
||||
|
||||
harness.abortController.abort();
|
||||
await Bun.sleep(0);
|
||||
|
||||
@@ -15,7 +15,7 @@ import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
|
||||
import { AsyncJobManager } from "@oh-my-pi/pi-coding-agent/async";
|
||||
import type { Rule } from "@oh-my-pi/pi-coding-agent/capability/rule";
|
||||
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { type SettingPath, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { TtsrManager } from "@oh-my-pi/pi-coding-agent/export/ttsr";
|
||||
import type { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions";
|
||||
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
||||
@@ -68,7 +68,7 @@ describe("AgentSession concurrent prompt guard", () => {
|
||||
AsyncJobManager.resetForTests();
|
||||
});
|
||||
|
||||
async function createSession() {
|
||||
async function createSession(settingsOverrides?: Partial<Record<SettingPath, unknown>>) {
|
||||
const model = getBundledModel("anthropic", "claude-sonnet-4-5")!;
|
||||
let abortSignal: AbortSignal | undefined;
|
||||
|
||||
@@ -100,7 +100,7 @@ describe("AgentSession concurrent prompt guard", () => {
|
||||
});
|
||||
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const settings = Settings.isolated();
|
||||
const settings = Settings.isolated(settingsOverrides);
|
||||
const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
|
||||
authStorages.push(authStorage);
|
||||
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml"));
|
||||
@@ -176,6 +176,86 @@ describe("AgentSession concurrent prompt guard", () => {
|
||||
await firstPrompt.catch(() => {});
|
||||
});
|
||||
|
||||
it("queues sendUserMessage as steer while streaming without AgentBusyError", async () => {
|
||||
await createSession();
|
||||
|
||||
const firstPrompt = session.prompt("First message");
|
||||
await waitFor(() => session.isStreaming);
|
||||
|
||||
// The first agent loop may dequeue a steer before the assertion runs, so
|
||||
// observe agent.steer itself rather than the residual queue length.
|
||||
const steered: AgentMessage[] = [];
|
||||
const originalSteer = session.agent.steer.bind(session.agent);
|
||||
session.agent.steer = (message: AgentMessage) => {
|
||||
steered.push(message);
|
||||
originalSteer(message);
|
||||
};
|
||||
|
||||
// Extension path: no deliverAs while busy must queue, not throw.
|
||||
await expect(session.sendUserMessage("hello from extension")).resolves.toBeUndefined();
|
||||
expect(steered).toHaveLength(1);
|
||||
const queued = steered[0];
|
||||
expect(queued?.role).toBe("user");
|
||||
if (queued?.role === "user") {
|
||||
expect(queued.content).toEqual([{ type: "text", text: "hello from extension" }]);
|
||||
expect(queued.steering).toBe(true);
|
||||
}
|
||||
|
||||
session.agent.clearAllQueues();
|
||||
await session.abort();
|
||||
await firstPrompt.catch(() => {});
|
||||
});
|
||||
|
||||
it("sendUserMessage without deliverAs preserves prompt-flow keyword notices while streaming", async () => {
|
||||
await createSession({ "magicKeywords.enabled": true, "magicKeywords.ultrathink": true });
|
||||
|
||||
const firstPrompt = session.prompt("First message");
|
||||
await waitFor(() => session.isStreaming);
|
||||
|
||||
try {
|
||||
await session.sendUserMessage("ultrathink fix via extension");
|
||||
const queuedShape = session.agent
|
||||
.peekSteeringQueue()
|
||||
.map(message => (message.role === "custom" ? message.customType : message.role));
|
||||
expect(queuedShape).toEqual(["ultrathink-notice", "user"]);
|
||||
expect(session.getQueuedMessages()).toEqual({
|
||||
steering: ["ultrathink fix via extension"],
|
||||
followUp: [],
|
||||
});
|
||||
} finally {
|
||||
session.agent.clearAllQueues();
|
||||
await session.abort();
|
||||
await firstPrompt.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it("sendUserMessage without deliverAs starts a normal prompt when idle", async () => {
|
||||
await createSession();
|
||||
|
||||
let rejected: unknown;
|
||||
let settled = false;
|
||||
const turn = session
|
||||
.sendUserMessage("Idle extension message")
|
||||
.catch(error => {
|
||||
rejected = error;
|
||||
})
|
||||
.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
try {
|
||||
await waitFor(() => session.isStreaming || settled);
|
||||
if (rejected) throw rejected;
|
||||
|
||||
expect(session.isStreaming).toBe(true);
|
||||
expect(settled).toBe(false);
|
||||
expect(session.getQueuedMessages()).toEqual({ steering: [], followUp: [] });
|
||||
} finally {
|
||||
await session.abort();
|
||||
await turn;
|
||||
}
|
||||
});
|
||||
|
||||
it("delivers hidden nextTurn stop reactions through the next LLM call without exposing them in the visible queue", async () => {
|
||||
const model = getBundledModel("anthropic", "claude-sonnet-4-5")!;
|
||||
let firstStream: AssistantMessageEventStream | undefined;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { Effort } from "@oh-my-pi/pi-ai";
|
||||
import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
||||
import { resolveAgentModelPatterns, resolveModelOverride } from "@oh-my-pi/pi-coding-agent/config/model-resolver";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { getBundledAgent } from "@oh-my-pi/pi-coding-agent/task/agents";
|
||||
|
||||
describe("bundled agent parsing", () => {
|
||||
it("lets reviewer inherit thinking effort from its model role", () => {
|
||||
const reviewer = getBundledAgent("reviewer");
|
||||
|
||||
expect(reviewer).toBeDefined();
|
||||
expect(reviewer?.source).toBe("bundled");
|
||||
expect(reviewer?.model).toEqual(["pi/slow"]);
|
||||
expect(reviewer?.thinkingLevel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lets plan inherit thinking effort from its model role", () => {
|
||||
const plan = getBundledAgent("plan");
|
||||
|
||||
expect(plan).toBeDefined();
|
||||
expect(plan?.source).toBe("bundled");
|
||||
expect(plan?.model).toEqual(["pi/plan", "pi/slow"]);
|
||||
expect(plan?.thinkingLevel).toBeUndefined();
|
||||
});
|
||||
|
||||
// Issue #4761: with `modelRoles.slow: ...:xhigh`, the role's explicit effort
|
||||
// suffix must survive agent-pattern expansion and model resolution for the
|
||||
// bundled agents routed at that role. The executor picks
|
||||
// `agent.thinkingLevel ?? resolvedThinkingLevel` (task/executor.ts), so a
|
||||
// bundled frontmatter pin would mask the suffix — reviewer/plan declare none
|
||||
// (asserted above) and the resolved level below is what the subagent runs at.
|
||||
it("resolves the configured slow-role effort suffix for reviewer and plan", () => {
|
||||
const gpt55 = buildModel({
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5 Codex",
|
||||
api: "openai-codex-responses",
|
||||
provider: "openai-codex",
|
||||
baseUrl: "https://chatgpt.com/backend-api/codex",
|
||||
reasoning: true,
|
||||
thinking: { mode: "effort", efforts: [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh] },
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 272000,
|
||||
maxTokens: 128000,
|
||||
});
|
||||
const settings = Settings.isolated({
|
||||
modelRoles: { slow: "openai-codex/gpt-5.5:xhigh", plan: "openai-codex/gpt-5.5:xhigh" },
|
||||
});
|
||||
const registry = { getAvailable: () => [gpt55] } as Parameters<typeof resolveModelOverride>[1];
|
||||
|
||||
for (const name of ["reviewer", "plan"]) {
|
||||
const agent = getBundledAgent(name);
|
||||
expect(agent?.thinkingLevel).toBeUndefined();
|
||||
const patterns = resolveAgentModelPatterns({ agentModel: agent?.model, settings });
|
||||
const resolved = resolveModelOverride(patterns, registry, settings);
|
||||
expect(resolved.model?.provider).toBe("openai-codex");
|
||||
expect(resolved.model?.id).toBe("gpt-5.5");
|
||||
expect(resolved.thinkingLevel).toBe(Effort.XHigh);
|
||||
expect(resolved.explicitThinkingLevel).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1169,6 +1169,7 @@ describe("ExtensionRunner", () => {
|
||||
setEditorText: () => {},
|
||||
getEditorText: () => "",
|
||||
editor: async () => undefined,
|
||||
addAutocompleteProvider: () => {},
|
||||
setEditorComponent: () => {},
|
||||
get theme() {
|
||||
return {} as never;
|
||||
|
||||
@@ -269,6 +269,16 @@ function abortViewSession(ctx: InteractiveModeContext): AbortViewSession {
|
||||
// so property access is explicit.
|
||||
return ctx.viewSession as unknown as AbortViewSession;
|
||||
}
|
||||
|
||||
type MutableSessionState = InteractiveModeContext["session"] & {
|
||||
isStreaming: boolean;
|
||||
};
|
||||
|
||||
function mutableSessionState(ctx: InteractiveModeContext): MutableSessionState {
|
||||
// Test harness installs a mutable fake AgentSession; keep the unchecked cast named
|
||||
// so state mutations are explicit.
|
||||
return ctx.session as MutableSessionState;
|
||||
}
|
||||
beforeEach(async () => {
|
||||
await Settings.init({ inMemory: true });
|
||||
});
|
||||
@@ -438,111 +448,37 @@ describe("InputController escape behavior", () => {
|
||||
expect(spies.abort).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires a second Esc within two seconds to abort streaming", () => {
|
||||
const now = vi.spyOn(Date, "now");
|
||||
now.mockReturnValue(1_000);
|
||||
it("aborts an active streaming turn on the first Esc without asking for confirmation", () => {
|
||||
const { ctx, editor, spies } = createContext();
|
||||
(ctx.session as { isStreaming: boolean }).isStreaming = true;
|
||||
mutableSessionState(ctx).isStreaming = true;
|
||||
const controller = new InputController(ctx);
|
||||
|
||||
controller.setupKeyHandlers();
|
||||
editor.onEscape?.();
|
||||
|
||||
expect(spies.abort).toHaveBeenCalledTimes(1);
|
||||
expect(spies.abort).toHaveBeenCalledWith({ reason: USER_INTERRUPT_LABEL });
|
||||
expect(spies.showStatus).not.toHaveBeenCalledWith("Press Esc again within 2s to cancel streaming.");
|
||||
});
|
||||
|
||||
it("aborts the submitted turn on the first Esc once the main session starts streaming", async () => {
|
||||
const { ctx, editor, spies } = createContext();
|
||||
const submission = createSubmission({ text: "fix issue #4921" });
|
||||
spies.startPendingSubmission.mockReturnValue(submission);
|
||||
const controller = new InputController(ctx);
|
||||
|
||||
controller.setupKeyHandlers();
|
||||
controller.setupEditorSubmitHandler();
|
||||
await editor.onSubmit?.("fix issue #4921");
|
||||
mutableSessionState(ctx).isStreaming = true;
|
||||
ctx.loadingAnimation = undefined;
|
||||
|
||||
editor.onEscape?.();
|
||||
|
||||
expect(spies.cancelPendingSubmission).not.toHaveBeenCalled();
|
||||
expect(spies.clearQueue).not.toHaveBeenCalled();
|
||||
expect(spies.abort).not.toHaveBeenCalled();
|
||||
expect(spies.showStatus).toHaveBeenCalledWith("Press Esc again within 2s to cancel streaming.");
|
||||
|
||||
now.mockReturnValue(2_500);
|
||||
editor.onEscape?.();
|
||||
|
||||
expect(spies.abort).toHaveBeenCalledTimes(1);
|
||||
expect(spies.abort).toHaveBeenCalledWith({ reason: USER_INTERRUPT_LABEL });
|
||||
});
|
||||
|
||||
it("expires the streaming Esc arm instead of aborting on a late second press", () => {
|
||||
const now = vi.spyOn(Date, "now");
|
||||
now.mockReturnValue(1_000);
|
||||
const { ctx, editor, spies } = createContext();
|
||||
(ctx.session as { isStreaming: boolean }).isStreaming = true;
|
||||
const controller = new InputController(ctx);
|
||||
|
||||
controller.setupKeyHandlers();
|
||||
editor.onEscape?.();
|
||||
now.mockReturnValue(3_001);
|
||||
editor.onEscape?.();
|
||||
|
||||
expect(spies.abort).not.toHaveBeenCalled();
|
||||
expect(spies.showStatus).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("preserves the streaming Esc arm when streamingComponent appears between presses", () => {
|
||||
// Pre-`message_start`: first Esc arms on the per-turn sentinel. `message_start`
|
||||
// then publishes `ctx.streamingComponent`; the second Esc must still abort the
|
||||
// same live turn instead of re-arming on the new component reference.
|
||||
const now = vi.spyOn(Date, "now");
|
||||
now.mockReturnValue(1_000);
|
||||
const { ctx, editor, spies } = createContext();
|
||||
(ctx.session as { isStreaming: boolean }).isStreaming = true;
|
||||
const controller = new InputController(ctx);
|
||||
|
||||
controller.setupKeyHandlers();
|
||||
editor.onEscape?.();
|
||||
(ctx as unknown as { streamingComponent: object }).streamingComponent = {};
|
||||
now.mockReturnValue(1_500);
|
||||
editor.onEscape?.();
|
||||
|
||||
expect(spies.abort).toHaveBeenCalledTimes(1);
|
||||
expect(spies.abort).toHaveBeenCalledWith({ reason: USER_INTERRUPT_LABEL });
|
||||
});
|
||||
|
||||
it("aborts on the second Esc even when ctx.streamingMessage was replaced by a delta in between", () => {
|
||||
// `EventController` replaces `ctx.streamingMessage` with a fresh immutable
|
||||
// snapshot on every `message_update`; the per-turn sentinel is unaffected so
|
||||
// swapping the message must not invalidate the armed token.
|
||||
const now = vi.spyOn(Date, "now");
|
||||
now.mockReturnValue(1_000);
|
||||
const { ctx, editor, spies } = createContext();
|
||||
(ctx.session as { isStreaming: boolean }).isStreaming = true;
|
||||
(ctx as unknown as { streamingComponent: object }).streamingComponent = {};
|
||||
(ctx as unknown as { streamingMessage: object }).streamingMessage = { content: [] };
|
||||
const controller = new InputController(ctx);
|
||||
|
||||
controller.setupKeyHandlers();
|
||||
editor.onEscape?.();
|
||||
(ctx as unknown as { streamingMessage: object }).streamingMessage = { content: ["delta"] };
|
||||
now.mockReturnValue(1_500);
|
||||
editor.onEscape?.();
|
||||
|
||||
expect(spies.abort).toHaveBeenCalledTimes(1);
|
||||
expect(spies.abort).toHaveBeenCalledWith({ reason: USER_INTERRUPT_LABEL });
|
||||
});
|
||||
|
||||
it("clears the streaming Esc arm when the current turn ends", () => {
|
||||
const now = vi.spyOn(Date, "now");
|
||||
now.mockReturnValue(1_000);
|
||||
const { ctx, editor, spies, sessionListeners } = createContext();
|
||||
(ctx.session as { isStreaming: boolean }).isStreaming = true;
|
||||
const controller = new InputController(ctx);
|
||||
|
||||
controller.setupKeyHandlers();
|
||||
// Fallback arm (no streamingMessage/streamingComponent yet — pre-message_start).
|
||||
editor.onEscape?.();
|
||||
expect(sessionListeners).toHaveLength(1);
|
||||
|
||||
// Turn 1 ends; a new turn starts. session.subscribe receives both transitions,
|
||||
// either of which must invalidate the still-armed fallback token so it cannot
|
||||
// fast-abort the new turn's first Esc.
|
||||
for (const listener of sessionListeners) {
|
||||
listener({ type: "agent_end" });
|
||||
listener({ type: "agent_start" });
|
||||
}
|
||||
|
||||
now.mockReturnValue(1_500);
|
||||
editor.onEscape?.();
|
||||
|
||||
expect(spies.abort).not.toHaveBeenCalled();
|
||||
expect(spies.showStatus).toHaveBeenCalledTimes(2);
|
||||
expect(spies.showStatus).not.toHaveBeenCalledWith("Press Esc again within 2s to cancel streaming.");
|
||||
});
|
||||
|
||||
it("returns focused subagent view to main on Esc instead of aborting", () => {
|
||||
|
||||
@@ -238,6 +238,59 @@ describe("MemoryProtocolHandler — mnemopi bridge (issue #4443)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves memory://<fact-id> to a read-only fact row (issue #4725)", async () => {
|
||||
await withMnemopiSession(async ({ state }) => {
|
||||
const beam = state.memory.beam;
|
||||
beam.db
|
||||
.prepare(
|
||||
"INSERT INTO facts (fact_id, session_id, subject, predicate, object, timestamp, confidence) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
"0473bbdb8da6df92",
|
||||
beam.sessionId,
|
||||
"Glab",
|
||||
"works-without",
|
||||
"mise prefix",
|
||||
"2026-07-01T00:00:00.000Z",
|
||||
0.9,
|
||||
);
|
||||
|
||||
const router = InternalUrlRouter.instance();
|
||||
const resource = await router.resolve("memory://0473bbdb8da6df92");
|
||||
|
||||
expect(resource.content).toContain("id: 0473bbdb8da6df92");
|
||||
expect(resource.content).toContain("store: fact");
|
||||
expect(resource.content).toContain("Glab works-without mise prefix");
|
||||
});
|
||||
});
|
||||
|
||||
it("reports not_editable (not not_found) for memory_edit ops on a fact id (issue #4725)", async () => {
|
||||
await withMnemopiSession(async ({ state }) => {
|
||||
const beam = state.memory.beam;
|
||||
beam.db
|
||||
.prepare(
|
||||
"INSERT INTO facts (fact_id, session_id, subject, predicate, object, timestamp, confidence) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run("fact-readonly", beam.sessionId, "service", "uses", "postgres", "2026-07-01T00:00:00.000Z", 0.9);
|
||||
|
||||
expect(state.editScopedMemory("update", "fact-readonly", { content: "x" })).toMatchObject({
|
||||
status: "not_editable",
|
||||
store: "fact",
|
||||
});
|
||||
expect(state.editScopedMemory("forget", "fact-readonly")).toMatchObject({
|
||||
status: "not_editable",
|
||||
store: "fact",
|
||||
});
|
||||
expect(state.editScopedMemory("invalidate", "fact-readonly")).toMatchObject({
|
||||
status: "not_editable",
|
||||
store: "fact",
|
||||
});
|
||||
|
||||
// The fact row itself is untouched by the rejected edits.
|
||||
expect(beam.db.prepare("SELECT fact_id FROM facts WHERE fact_id = ?").get("fact-readonly")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("routes memory://root to the file-backed summary even when mnemopi is active", async () => {
|
||||
await withMnemopiSession(async () => {
|
||||
const router = InternalUrlRouter.instance();
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Issue #4919: a pi extension calling `ctx.ui.addAutocompleteProvider(...)` in its
|
||||
* `session_start` handler crashed at load under omp — the method was absent from
|
||||
* `ExtensionUIContext`, so the call threw `TypeError: ... is not a function` and
|
||||
* (for extensions that wrap init in try/catch, e.g. @ff-labs/pi-fff) aborted the
|
||||
* extension's entire initialization.
|
||||
*
|
||||
* These tests pin the pi-compatible contract:
|
||||
* - headless contexts accept the factory as a no-op instead of throwing, and
|
||||
* - interactive mode stacks each factory on top of the built-in editor provider.
|
||||
*
|
||||
* NOTE: imports are relative (`../src/...`) so the tests exercise this checkout
|
||||
* even when `node_modules/@oh-my-pi/pi-coding-agent` resolves elsewhere.
|
||||
*/
|
||||
|
||||
import { afterAll, afterEach, beforeAll, 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 { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core";
|
||||
import { type Api, Effort, type Model } from "@oh-my-pi/pi-ai";
|
||||
import type { AutocompleteProvider } from "@oh-my-pi/pi-tui";
|
||||
import { logger, TempDir } from "@oh-my-pi/pi-utils";
|
||||
import { type } from "arktype";
|
||||
import { ModelRegistry } from "../src/config/model-registry";
|
||||
import { resetSettingsForTest, Settings } from "../src/config/settings";
|
||||
import { loadExtensions } from "../src/extensibility/extensions/loader";
|
||||
import { ExtensionRunner } from "../src/extensibility/extensions/runner";
|
||||
import { InteractiveMode } from "../src/modes/interactive-mode";
|
||||
import { initTheme } from "../src/modes/theme/theme";
|
||||
import { AgentSession } from "../src/session/agent-session";
|
||||
import { AuthStorage } from "../src/session/auth-storage";
|
||||
import { SessionManager } from "../src/session/session-manager";
|
||||
|
||||
function makeTool(name: string): AgentTool {
|
||||
return {
|
||||
name,
|
||||
label: name,
|
||||
description: `Fake ${name}`,
|
||||
parameters: type({}),
|
||||
async execute() {
|
||||
return { content: [{ type: "text" as const, text: "ok" }] };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap `current` the way a well-behaved pi extension does: contribute items for
|
||||
* its own trigger prefix, delegate everything else to the wrapped provider.
|
||||
*/
|
||||
function makeWrappingFactory(tag: string): (current: AutocompleteProvider) => AutocompleteProvider {
|
||||
return current => ({
|
||||
async getSuggestions(lines, cursorLine, cursorCol) {
|
||||
const line = lines[cursorLine] ?? "";
|
||||
if (line.startsWith("##")) {
|
||||
const base = await current.getSuggestions(lines, cursorLine, cursorCol);
|
||||
return {
|
||||
items: [...(base?.items ?? []), { value: tag, label: tag }],
|
||||
prefix: base?.prefix ?? line.slice(0, cursorCol),
|
||||
};
|
||||
}
|
||||
return current.getSuggestions(lines, cursorLine, cursorCol);
|
||||
},
|
||||
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
||||
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("extension autocomplete provider API (#4919)", () => {
|
||||
let tempDir: TempDir;
|
||||
let authStorage: AuthStorage;
|
||||
let registry: ModelRegistry;
|
||||
let model: Model<Api>;
|
||||
let tools: AgentTool[];
|
||||
let originalHome: string | undefined;
|
||||
let mode: InteractiveMode | undefined;
|
||||
let session: AgentSession | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
initTheme();
|
||||
resetSettingsForTest();
|
||||
// One empty temp dir doubles as the project cwd and the (isolated) home
|
||||
// directory, keeping `refreshSlashCommandState`'s capability scan off the
|
||||
// real home dir (mirrors the prompt-template autocomplete harness).
|
||||
tempDir = TempDir.createSync("@pi-ext-autocomplete-");
|
||||
originalHome = process.env.HOME;
|
||||
process.env.HOME = tempDir.path();
|
||||
await Settings.init({ inMemory: true, cwd: tempDir.path() });
|
||||
Settings.instance.set("startup.quiet", true);
|
||||
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
|
||||
authStorage.setRuntimeApiKey("anthropic", "test-key");
|
||||
registry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
|
||||
const resolved = registry.find("anthropic", "claude-sonnet-4-5");
|
||||
if (!resolved) throw new Error("Expected anthropic model claude-sonnet-4-5 to exist");
|
||||
model = resolved;
|
||||
tools = [makeTool("read")];
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(os, "homedir").mockReturnValue(tempDir.path());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
mode?.stop();
|
||||
await session?.dispose();
|
||||
mode = undefined;
|
||||
session = undefined;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
authStorage?.close();
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
tempDir?.removeSync();
|
||||
resetSettingsForTest();
|
||||
});
|
||||
|
||||
function createHarness(): { mode: InteractiveMode; session: AgentSession } {
|
||||
const manager = SessionManager.create(tempDir.path(), path.join(tempDir.path(), `active-${Bun.nanoseconds()}`));
|
||||
const created = new AgentSession({
|
||||
agent: new Agent({
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: ["Test"],
|
||||
tools,
|
||||
messages: [],
|
||||
thinkingLevel: Effort.Medium,
|
||||
},
|
||||
}),
|
||||
sessionManager: manager,
|
||||
settings: Settings.isolated({ "compaction.enabled": false }),
|
||||
modelRegistry: registry,
|
||||
toolRegistry: new Map(tools.map(tool => [tool.name, tool])),
|
||||
promptTemplates: [],
|
||||
});
|
||||
const createdMode = new InteractiveMode(created, "test");
|
||||
session = created;
|
||||
mode = createdMode;
|
||||
return { mode: createdMode, session: created };
|
||||
}
|
||||
|
||||
function captureAutocompleteProvider(target: InteractiveMode): { current: AutocompleteProvider | undefined } {
|
||||
const slot: { current: AutocompleteProvider | undefined } = { current: undefined };
|
||||
vi.spyOn(target.editor, "setAutocompleteProvider").mockImplementation(provider => {
|
||||
slot.current = provider;
|
||||
});
|
||||
return slot;
|
||||
}
|
||||
|
||||
it("does not abort a session_start handler that registers a provider without UI", async () => {
|
||||
// Mimics @ff-labs/pi-fff: registerAutocompleteProvider(ctx) runs first and
|
||||
// unconditionally inside the try/catch that guards the whole init routine.
|
||||
const extensionsDir = path.join(tempDir.path(), "runner-extensions");
|
||||
fs.mkdirSync(extensionsDir, { recursive: true });
|
||||
const markerPath = path.join(extensionsDir, "init-marker.txt");
|
||||
const extPath = path.join(extensionsDir, "fff-like.ts");
|
||||
fs.writeFileSync(
|
||||
extPath,
|
||||
`import * as fs from "node:fs";
|
||||
export default function (pi) {
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
try {
|
||||
ctx.ui.addAutocompleteProvider((current) => current);
|
||||
// "Rest of init" — on baseline the call above throws and this never runs.
|
||||
fs.writeFileSync(${JSON.stringify(markerPath)}, "initialized");
|
||||
} catch (error) {
|
||||
fs.writeFileSync(
|
||||
${JSON.stringify(markerPath)},
|
||||
"failed: " + (error instanceof Error ? error.message : String(error)),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const result = await loadExtensions([extPath], tempDir.path());
|
||||
expect(result.errors).toEqual([]);
|
||||
const runner = new ExtensionRunner(
|
||||
result.extensions,
|
||||
result.runtime,
|
||||
tempDir.path(),
|
||||
SessionManager.inMemory(),
|
||||
registry,
|
||||
);
|
||||
const surfaced: string[] = [];
|
||||
runner.onError(error => {
|
||||
surfaced.push(error.error);
|
||||
});
|
||||
|
||||
await runner.emit({ type: "session_start" });
|
||||
|
||||
expect(surfaced).toEqual([]);
|
||||
expect(fs.readFileSync(markerPath, "utf8")).toBe("initialized");
|
||||
});
|
||||
|
||||
it("stacks extension factories on top of the built-in editor provider", async () => {
|
||||
const created = createHarness();
|
||||
const slot = captureAutocompleteProvider(created.mode);
|
||||
|
||||
// Registration before the first refresh (session_start fires before init's
|
||||
// refreshSlashCommandState) must land once the base provider exists.
|
||||
created.mode.addAutocompleteProvider(makeWrappingFactory("##fff-first"));
|
||||
await created.mode.refreshSlashCommandState(tempDir.path());
|
||||
|
||||
const provider = slot.current;
|
||||
expect(provider).toBeDefined();
|
||||
|
||||
// The extension's trigger prefix surfaces its items...
|
||||
const extension = await provider!.getSuggestions(["##"], 0, 2);
|
||||
expect(extension?.items.map(item => item.value)).toContain("##fff-first");
|
||||
|
||||
// ...while built-in slash completion still flows through the wrapper.
|
||||
const slash = await provider!.getSuggestions(["/"], 0, 1);
|
||||
expect(slash?.items.map(item => item.value)).toContain("model");
|
||||
|
||||
// Registration after the refresh re-applies immediately, preserving the chain.
|
||||
created.mode.addAutocompleteProvider(makeWrappingFactory("##fff-second"));
|
||||
const restacked = slot.current;
|
||||
expect(restacked).toBeDefined();
|
||||
expect(restacked).not.toBe(provider);
|
||||
|
||||
const chained = await restacked!.getSuggestions(["##"], 0, 2);
|
||||
const values = chained?.items.map(item => item.value) ?? [];
|
||||
expect(values).toContain("##fff-first");
|
||||
expect(values).toContain("##fff-second");
|
||||
});
|
||||
|
||||
it("skips broken factories without losing core autocomplete or healthy wrappers", async () => {
|
||||
const created = createHarness();
|
||||
const slot = captureAutocompleteProvider(created.mode);
|
||||
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
||||
|
||||
created.mode.addAutocompleteProvider(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
created.mode.addAutocompleteProvider(() => ({}) as AutocompleteProvider);
|
||||
created.mode.addAutocompleteProvider(makeWrappingFactory("##healthy"));
|
||||
await created.mode.refreshSlashCommandState(tempDir.path());
|
||||
|
||||
const provider = slot.current;
|
||||
expect(provider).toBeDefined();
|
||||
|
||||
const slash = await provider!.getSuggestions(["/"], 0, 1);
|
||||
expect(slash?.items.map(item => item.value)).toContain("model");
|
||||
|
||||
const extension = await provider!.getSuggestions(["##"], 0, 2);
|
||||
expect(extension?.items.map(item => item.value)).toContain("##healthy");
|
||||
|
||||
expect(warnSpy.mock.calls.some(([message]) => String(message).includes("autocomplete provider factory"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,13 +4,32 @@ import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { KeybindingsManager } from "@oh-my-pi/pi-coding-agent/config/keybindings";
|
||||
import { matchesAppFollowUp } from "@oh-my-pi/pi-coding-agent/modes/utils/keybinding-matchers";
|
||||
import { setKeybindings } from "@oh-my-pi/pi-tui";
|
||||
import { removeWithRetries } from "@oh-my-pi/pi-utils";
|
||||
import { type KeybindingsConfig, setKeybindings } from "@oh-my-pi/pi-tui";
|
||||
import {
|
||||
__resetDirsFromEnvForTests,
|
||||
getAgentDir,
|
||||
getProfileRootDir,
|
||||
removeWithRetries,
|
||||
setProfile,
|
||||
} from "@oh-my-pi/pi-utils";
|
||||
import { YAML } from "bun";
|
||||
|
||||
function ctrl(key: string): string {
|
||||
return String.fromCharCode(key.toLowerCase().charCodeAt(0) & 31);
|
||||
}
|
||||
|
||||
async function writeKeybindingsYaml(agentDir: string, config: KeybindingsConfig): Promise<void> {
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
await Bun.write(path.join(agentDir, "keybindings.yml"), YAML.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
function restoreEnvValue(key: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
describe("KeybindingsManager.create", () => {
|
||||
beforeEach(() => {
|
||||
setKeybindings(KeybindingsManager.inMemory());
|
||||
@@ -149,6 +168,117 @@ describe("KeybindingsManager.create", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("inherits default user keybindings for a named profile without a profile keybindings file (#4867)", async () => {
|
||||
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-keybindings-profile-"));
|
||||
const defaultAgentDir = path.join(rootDir, "default", "agent");
|
||||
const profileAgentDir = path.join(rootDir, "profiles", "work", "agent");
|
||||
|
||||
await writeKeybindingsYaml(defaultAgentDir, {
|
||||
"app.session.fork": "ctrl+f",
|
||||
"tui.editor.deleteCharBackward": ["backspace", "ctrl+h"],
|
||||
});
|
||||
|
||||
try {
|
||||
const manager = KeybindingsManager.create(profileAgentDir, { inheritedAgentDir: defaultAgentDir });
|
||||
|
||||
expect(manager.getKeys("app.session.fork")).toEqual(["ctrl+f"]);
|
||||
expect(manager.getKeys("tui.editor.deleteCharBackward")).toEqual(["backspace", "ctrl+h"]);
|
||||
} finally {
|
||||
await removeWithRetries(rootDir);
|
||||
}
|
||||
});
|
||||
|
||||
it("merges default user keybindings with profile overrides for a named profile (#4867)", async () => {
|
||||
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-keybindings-profile-"));
|
||||
const defaultAgentDir = path.join(rootDir, "default", "agent");
|
||||
const profileAgentDir = path.join(rootDir, "profiles", "work", "agent");
|
||||
|
||||
await writeKeybindingsYaml(defaultAgentDir, {
|
||||
"app.session.fork": "ctrl+f",
|
||||
"app.session.new": "ctrl+n",
|
||||
});
|
||||
await writeKeybindingsYaml(profileAgentDir, {
|
||||
"app.session.fork": "alt+f",
|
||||
"app.clipboard.copyLine": "alt+l",
|
||||
});
|
||||
|
||||
try {
|
||||
const manager = KeybindingsManager.create(profileAgentDir, { inheritedAgentDir: defaultAgentDir });
|
||||
|
||||
expect(manager.getKeys("app.session.new")).toEqual(["ctrl+n"]);
|
||||
expect(manager.getKeys("app.session.fork")).toEqual(["alt+f"]);
|
||||
expect(manager.getKeys("app.clipboard.copyLine")).toEqual(["alt+l"]);
|
||||
} finally {
|
||||
await removeWithRetries(rootDir);
|
||||
}
|
||||
});
|
||||
|
||||
it("never writes migration output into the inherited default agent dir (#4867)", async () => {
|
||||
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-keybindings-profile-"));
|
||||
const defaultAgentDir = path.join(rootDir, "default", "agent");
|
||||
const profileAgentDir = path.join(rootDir, "profiles", "work", "agent");
|
||||
|
||||
// Legacy JSON in the default dir: loading it with a write-back path would
|
||||
// materialize keybindings.yml there. The inherited load must stay read-only.
|
||||
await fs.mkdir(defaultAgentDir, { recursive: true });
|
||||
await Bun.write(
|
||||
path.join(defaultAgentDir, "keybindings.json"),
|
||||
JSON.stringify({ "app.session.fork": "ctrl+f" }, null, 2),
|
||||
);
|
||||
|
||||
try {
|
||||
const manager = KeybindingsManager.create(profileAgentDir, { inheritedAgentDir: defaultAgentDir });
|
||||
|
||||
expect(manager.getKeys("app.session.fork")).toEqual(["ctrl+f"]);
|
||||
expect(await Bun.file(path.join(defaultAgentDir, "keybindings.yml")).exists()).toBe(false);
|
||||
} finally {
|
||||
await removeWithRetries(rootDir);
|
||||
}
|
||||
});
|
||||
|
||||
it("merges default user keybindings when create uses the active profile with no arguments (#4867)", async () => {
|
||||
const originalConfigDir = process.env.PI_CONFIG_DIR;
|
||||
const originalAgentDirEnv = process.env.PI_CODING_AGENT_DIR;
|
||||
const originalOmpProfile = process.env.OMP_PROFILE;
|
||||
const originalPiProfile = process.env.PI_PROFILE;
|
||||
const configRootDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-keybindings-active-profile-"));
|
||||
|
||||
try {
|
||||
process.env.PI_CONFIG_DIR = path.relative(os.homedir(), configRootDir);
|
||||
restoreEnvValue("PI_CODING_AGENT_DIR", originalAgentDirEnv);
|
||||
restoreEnvValue("OMP_PROFILE", originalOmpProfile);
|
||||
restoreEnvValue("PI_PROFILE", originalPiProfile);
|
||||
__resetDirsFromEnvForTests();
|
||||
|
||||
const defaultAgentDir = path.join(getProfileRootDir(undefined), "agent");
|
||||
const profileAgentDir = path.join(getProfileRootDir("work"), "agent");
|
||||
await writeKeybindingsYaml(defaultAgentDir, {
|
||||
"app.session.fork": "ctrl+f",
|
||||
"app.session.new": "ctrl+n",
|
||||
});
|
||||
await writeKeybindingsYaml(profileAgentDir, {
|
||||
"app.session.fork": "alt+f",
|
||||
"app.clipboard.copyLine": "alt+l",
|
||||
});
|
||||
|
||||
setProfile("work");
|
||||
|
||||
expect(getAgentDir()).toBe(profileAgentDir);
|
||||
const manager = KeybindingsManager.create();
|
||||
|
||||
expect(manager.getKeys("app.session.new")).toEqual(["ctrl+n"]);
|
||||
expect(manager.getKeys("app.session.fork")).toEqual(["alt+f"]);
|
||||
expect(manager.getKeys("app.clipboard.copyLine")).toEqual(["alt+l"]);
|
||||
} finally {
|
||||
restoreEnvValue("PI_CONFIG_DIR", originalConfigDir);
|
||||
restoreEnvValue("PI_CODING_AGENT_DIR", originalAgentDirEnv);
|
||||
restoreEnvValue("OMP_PROFILE", originalOmpProfile);
|
||||
restoreEnvValue("PI_PROFILE", originalPiProfile);
|
||||
__resetDirsFromEnvForTests();
|
||||
await removeWithRetries(configRootDir);
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults model selection to Alt+M and display reset to Ctrl+L", () => {
|
||||
const manager = KeybindingsManager.inMemory();
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { Effort, type FetchImpl, type Model } from "@oh-my-pi/pi-ai";
|
||||
import type { OAuthCredentials } from "@oh-my-pi/pi-ai/oauth/types";
|
||||
import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
||||
import { writeModelCache } from "@oh-my-pi/pi-catalog/model-cache";
|
||||
import type { OpenAICompat } from "@oh-my-pi/pi-catalog/types";
|
||||
@@ -19,15 +20,18 @@ describe("ModelRegistry runtime discovery", () => {
|
||||
let originalOllamaBaseUrl: string | undefined;
|
||||
let originalOllamaHost: string | undefined;
|
||||
let originalOllamaContextLength: string | undefined;
|
||||
let originalAnthropicApiKey: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
resetSettingsForTest();
|
||||
originalOllamaBaseUrl = Bun.env.OLLAMA_BASE_URL;
|
||||
originalOllamaHost = Bun.env.OLLAMA_HOST;
|
||||
originalOllamaContextLength = Bun.env.OLLAMA_CONTEXT_LENGTH;
|
||||
originalAnthropicApiKey = Bun.env.ANTHROPIC_API_KEY;
|
||||
delete Bun.env.OLLAMA_BASE_URL;
|
||||
delete Bun.env.OLLAMA_HOST;
|
||||
delete Bun.env.OLLAMA_CONTEXT_LENGTH;
|
||||
delete Bun.env.ANTHROPIC_API_KEY;
|
||||
tempDir = path.join(os.tmpdir(), `pi-test-model-registry-${Snowflake.next()}`);
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
modelsJsonPath = path.join(tempDir, "models.json");
|
||||
@@ -55,6 +59,11 @@ describe("ModelRegistry runtime discovery", () => {
|
||||
} else {
|
||||
Bun.env.OLLAMA_CONTEXT_LENGTH = originalOllamaContextLength;
|
||||
}
|
||||
if (originalAnthropicApiKey === undefined) {
|
||||
delete Bun.env.ANTHROPIC_API_KEY;
|
||||
} else {
|
||||
Bun.env.ANTHROPIC_API_KEY = originalAnthropicApiKey;
|
||||
}
|
||||
authStorage.close();
|
||||
if (tempDir && fs.existsSync(tempDir)) {
|
||||
removeSyncWithRetries(tempDir);
|
||||
@@ -115,6 +124,197 @@ describe("ModelRegistry runtime discovery", () => {
|
||||
};
|
||||
}
|
||||
|
||||
async function useAuthStorageWithRefreshTracker() {
|
||||
authStorage.close();
|
||||
const refreshCalls: string[] = [];
|
||||
authStorage = await AuthStorage.create(":memory:", {
|
||||
refreshOAuthCredential: async (provider, _credentialId, credential): Promise<OAuthCredentials> => {
|
||||
refreshCalls.push(provider);
|
||||
return {
|
||||
...credential,
|
||||
access: provider === "anthropic" ? "sk-ant-oat-fresh-anthropic" : `fresh-${provider}`,
|
||||
expires: Date.now() + 3_600_000,
|
||||
};
|
||||
},
|
||||
});
|
||||
return { refreshCalls };
|
||||
}
|
||||
|
||||
type AnthropicDiscoveryCapture = {
|
||||
modelListAuthorization?: string | null;
|
||||
modelListXApiKey?: string | null;
|
||||
modelListCalls: number;
|
||||
};
|
||||
|
||||
function mockAnthropicModelsDiscovery(capture: AnthropicDiscoveryCapture): FetchImpl {
|
||||
const endpointPrefix = "https://api.anthropic.com/";
|
||||
return async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url === "https://models.dev/api.json") {
|
||||
return Response.json({});
|
||||
}
|
||||
if (url.startsWith(endpointPrefix) && url.endsWith("/models")) {
|
||||
const headers = new Headers(init?.headers);
|
||||
capture.modelListAuthorization = headers.get("authorization");
|
||||
capture.modelListXApiKey = headers.get("x-api-key");
|
||||
capture.modelListCalls++;
|
||||
return Response.json({
|
||||
data: [{ id: "claude-regression-4893", display_name: "Claude Regression 4893" }],
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
};
|
||||
}
|
||||
|
||||
test("refreshProvider online refreshes expired anthropic OAuth before model discovery", async () => {
|
||||
const { refreshCalls } = await useAuthStorageWithRefreshTracker();
|
||||
await authStorage.set("anthropic", {
|
||||
type: "oauth",
|
||||
access: "sk-ant-oat-expired-anthropic",
|
||||
refresh: "refresh-anthropic",
|
||||
expires: Date.now() - 60_000,
|
||||
});
|
||||
const capture: AnthropicDiscoveryCapture = { modelListCalls: 0 };
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath, {
|
||||
fetch: mockAnthropicModelsDiscovery(capture),
|
||||
});
|
||||
|
||||
await registry.refreshProvider("anthropic", "online");
|
||||
|
||||
expect(refreshCalls).toEqual(["anthropic"]);
|
||||
expect(capture.modelListCalls).toBe(1);
|
||||
expect(capture.modelListAuthorization).toBe("Bearer sk-ant-oat-fresh-anthropic");
|
||||
expect(capture.modelListXApiKey).toBeNull();
|
||||
expect(registry.find("anthropic", "claude-regression-4893")).toBeDefined();
|
||||
});
|
||||
|
||||
test("refreshProvider online does not refresh unrelated expired OAuth credentials", async () => {
|
||||
const { refreshCalls } = await useAuthStorageWithRefreshTracker();
|
||||
await authStorage.set("anthropic", {
|
||||
type: "oauth",
|
||||
access: "sk-ant-oat-expired-anthropic",
|
||||
refresh: "refresh-anthropic",
|
||||
expires: Date.now() - 60_000,
|
||||
});
|
||||
await authStorage.set("openai", {
|
||||
type: "oauth",
|
||||
access: "expired-openai",
|
||||
refresh: "refresh-openai",
|
||||
expires: Date.now() - 60_000,
|
||||
});
|
||||
const capture: AnthropicDiscoveryCapture = { modelListCalls: 0 };
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath, {
|
||||
fetch: mockAnthropicModelsDiscovery(capture),
|
||||
});
|
||||
|
||||
await registry.refreshProvider("anthropic", "online");
|
||||
|
||||
expect(refreshCalls).toEqual(["anthropic"]);
|
||||
expect(authStorage.getOAuthCredential("openai")?.access).toBe("expired-openai");
|
||||
expect(capture.modelListCalls).toBe(1);
|
||||
});
|
||||
|
||||
test("refreshProvider offline does not touch expired OAuth credentials", async () => {
|
||||
const { refreshCalls } = await useAuthStorageWithRefreshTracker();
|
||||
await authStorage.set("anthropic", {
|
||||
type: "oauth",
|
||||
access: "sk-ant-oat-expired-anthropic",
|
||||
refresh: "refresh-anthropic",
|
||||
expires: Date.now() - 60_000,
|
||||
});
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath, {
|
||||
fetch: async input => {
|
||||
throw new Error(`Offline discovery should not fetch ${String(input)}`);
|
||||
},
|
||||
});
|
||||
|
||||
await registry.refreshProvider("anthropic", "offline");
|
||||
|
||||
expect(refreshCalls).toEqual([]);
|
||||
expect(authStorage.getOAuthCredential("anthropic")?.access).toBe("sk-ant-oat-expired-anthropic");
|
||||
});
|
||||
test("online-if-uncached refreshes expired OAuth when the discovery cache is stale for the model manager", async () => {
|
||||
const { refreshCalls } = await useAuthStorageWithRefreshTracker();
|
||||
await authStorage.set("anthropic", {
|
||||
type: "oauth",
|
||||
access: "sk-ant-oat-expired-anthropic",
|
||||
refresh: "refresh-anthropic",
|
||||
expires: Date.now() - 60_000,
|
||||
});
|
||||
// Older than the model manager's 2h default TTL: the manager WILL fetch,
|
||||
// so the preflight must mint a fresh bearer first.
|
||||
writeModelCache("anthropic", Date.now() - 3 * 60 * 60 * 1000, [], true, "", cacheDbPath);
|
||||
const capture: AnthropicDiscoveryCapture = { modelListCalls: 0 };
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath, {
|
||||
fetch: mockAnthropicModelsDiscovery(capture),
|
||||
});
|
||||
|
||||
await registry.refreshProvider("anthropic", "online-if-uncached");
|
||||
|
||||
expect(refreshCalls).toEqual(["anthropic"]);
|
||||
expect(capture.modelListCalls).toBe(1);
|
||||
expect(capture.modelListAuthorization).toBe("Bearer sk-ant-oat-fresh-anthropic");
|
||||
});
|
||||
|
||||
test("online-if-uncached leaves expired OAuth untouched when the discovery cache is fresh", async () => {
|
||||
const { refreshCalls } = await useAuthStorageWithRefreshTracker();
|
||||
await authStorage.set("anthropic", {
|
||||
type: "oauth",
|
||||
access: "sk-ant-oat-expired-anthropic",
|
||||
refresh: "refresh-anthropic",
|
||||
expires: Date.now() - 60_000,
|
||||
});
|
||||
// Fresh authoritative cache: the manager will not fetch, so opening a
|
||||
// cached model selector must not rotate (or risk disabling) credentials.
|
||||
writeModelCache("anthropic", Date.now() - 60_000, [], true, "", cacheDbPath);
|
||||
const capture: AnthropicDiscoveryCapture = { modelListCalls: 0 };
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath, {
|
||||
fetch: mockAnthropicModelsDiscovery(capture),
|
||||
});
|
||||
|
||||
await registry.refreshProvider("anthropic", "online-if-uncached");
|
||||
|
||||
expect(refreshCalls).toEqual([]);
|
||||
expect(capture.modelListCalls).toBe(0);
|
||||
expect(authStorage.getOAuthCredential("anthropic")?.access).toBe("sk-ant-oat-expired-anthropic");
|
||||
});
|
||||
|
||||
test("configured discovery suppresses built-in special OAuth discovery", async () => {
|
||||
await authStorage.set("google-gemini-cli", {
|
||||
type: "oauth",
|
||||
access: "fresh-google-gemini-cli",
|
||||
refresh: "refresh-google-gemini-cli",
|
||||
expires: Date.now() + 3_600_000,
|
||||
});
|
||||
writeRawModelsJson({
|
||||
"google-gemini-cli": {
|
||||
baseUrl: "http://127.0.0.1:4893",
|
||||
api: "openai-completions",
|
||||
auth: "none",
|
||||
discovery: { type: "openai-models-list" },
|
||||
},
|
||||
});
|
||||
const unexpectedUrls: string[] = [];
|
||||
const fetchMock: FetchImpl = async input => {
|
||||
const url = String(input);
|
||||
if (url === "http://127.0.0.1:4893/v1/models") {
|
||||
return Response.json({
|
||||
data: [{ id: "configured-gemini-cli-model", context_length: 65_536 }],
|
||||
});
|
||||
}
|
||||
unexpectedUrls.push(url);
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
};
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock });
|
||||
|
||||
await registry.refreshProvider("google-gemini-cli", "online");
|
||||
|
||||
expect(unexpectedUrls).toEqual([]);
|
||||
const configuredModel = registry.find("google-gemini-cli", "configured-gemini-cli-model");
|
||||
expect(configuredModel?.baseUrl).toBe("http://127.0.0.1:4893");
|
||||
expect(configuredModel?.contextWindow).toBe(65_536);
|
||||
});
|
||||
|
||||
test("auto-discovers ollama models without provider config", async () => {
|
||||
const fetchMock = mockOllamaDiscovery(["phi4-mini"]);
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock });
|
||||
|
||||
@@ -261,6 +261,52 @@ describe("ModelRegistry runtime provider registration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("configured discovery suppresses extension fetchDynamicModels for the same provider", async () => {
|
||||
const providerName = "runtime-configured-provider";
|
||||
fs.writeFileSync(
|
||||
modelsJsonPath,
|
||||
JSON.stringify({
|
||||
providers: {
|
||||
[providerName]: {
|
||||
baseUrl: "http://127.0.0.1:4893",
|
||||
api: "openai-completions",
|
||||
auth: "none",
|
||||
discovery: { type: "openai-models-list" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const configuredFetch: FetchImpl = async input => {
|
||||
const url = String(input);
|
||||
if (url === "http://127.0.0.1:4893/v1/models") {
|
||||
return Response.json({
|
||||
data: [{ id: "shared-runtime-model", context_length: 32_768 }],
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
};
|
||||
const configuredRegistry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: configuredFetch });
|
||||
let runtimeFetchCalls = 0;
|
||||
configuredRegistry.registerProvider(
|
||||
providerName,
|
||||
{
|
||||
baseUrl: "https://runtime.example.com/v1",
|
||||
apiKey: "RUNTIME_KEY",
|
||||
api: "openai-completions",
|
||||
fetchDynamicModels: async () => {
|
||||
runtimeFetchCalls++;
|
||||
return [{ ...baseModel, id: "shared-runtime-model", contextWindow: 999_999 }];
|
||||
},
|
||||
},
|
||||
"ext://runtime",
|
||||
);
|
||||
|
||||
await configuredRegistry.refreshProvider(providerName, "online");
|
||||
|
||||
expect(runtimeFetchCalls).toBe(0);
|
||||
expect(configuredRegistry.find(providerName, "shared-runtime-model")?.contextWindow).toBe(32_768);
|
||||
});
|
||||
|
||||
test("refreshRuntimeProviders times out extension fetchDynamicModels that never resolves", async () => {
|
||||
vi.useFakeTimers();
|
||||
const hangingFetch = Promise.withResolvers<readonly NonNullable<ProviderConfigInput["models"]>[number][]>();
|
||||
|
||||
@@ -727,3 +727,163 @@ describe("TranscriptContainer renderViewportTail", () => {
|
||||
expect([...container.renderViewportTail(W, 0)]).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// A displaceable snapshot (todo/poll card): kept unfinalized only so a matching
|
||||
// follow-up call can retract it. Mirrors ToolExecutionComponent.seal — sealing
|
||||
// finalizes the block in place and it stops reporting displaceable. A pending
|
||||
// tool starts non-displaceable and becomes a displaceable snapshot only when
|
||||
// its successful result arrives (`makeDisplaceable`).
|
||||
class DisplaceableBlock implements Component {
|
||||
sealCount = 0;
|
||||
#sealed = false;
|
||||
#displaceable: boolean;
|
||||
#lines: string[];
|
||||
constructor(lines: string[], displaceable = true) {
|
||||
this.#lines = lines;
|
||||
this.#displaceable = displaceable;
|
||||
}
|
||||
makeDisplaceable(): void {
|
||||
this.#displaceable = true;
|
||||
}
|
||||
isTranscriptBlockFinalized(): boolean {
|
||||
return this.#sealed;
|
||||
}
|
||||
isDisplaceableBlock(): boolean {
|
||||
return this.#displaceable && !this.#sealed;
|
||||
}
|
||||
seal(): void {
|
||||
this.sealCount++;
|
||||
this.#sealed = true;
|
||||
}
|
||||
invalidate(): void {}
|
||||
render(_width: number): string[] {
|
||||
return [...this.#lines];
|
||||
}
|
||||
}
|
||||
|
||||
// Seal-on-commit: rows on the native-scrollback tape are immutable, so once the
|
||||
// commit boundary covers any of a displaceable snapshot's rows the container
|
||||
// must seal it in place — retracting it would strand an orphaned copy in
|
||||
// terminal history, and left unfinalized it would pin the live-region seam
|
||||
// open. setNativeScrollbackCommittedRows is a pure store; the seal walk runs at
|
||||
// the start of the NEXT render, over the previous frame's segments (the
|
||||
// geometry the committed count was computed against), before the seam scan so
|
||||
// the seam unpins in that same frame.
|
||||
describe("TranscriptContainer seal-on-commit", () => {
|
||||
const W = 40;
|
||||
|
||||
// history(0) | sep(1) | todo-header(2) | todo-body(3); the leading
|
||||
// separator row belongs to the card's segment (segment.startRow = 1).
|
||||
function cardAfterHistory(displaceable = true): { container: TranscriptContainer; card: DisplaceableBlock } {
|
||||
const container = new TranscriptContainer();
|
||||
container.addChild(new MutableBlock(["history"]));
|
||||
const card = new DisplaceableBlock(["todo-header", "todo-body"], displaceable);
|
||||
container.addChild(card);
|
||||
expect(container.render(W)).toEqual(["history", "", "todo-header", "todo-body"]);
|
||||
return { container, card };
|
||||
}
|
||||
|
||||
it("seals on the next render once the boundary covers the block's rows", () => {
|
||||
const { container, card } = cardAfterHistory();
|
||||
// The unsealed card pins the live-region seam at its own rows.
|
||||
expect(container.getNativeScrollbackLiveRegionStart()).toBe(2);
|
||||
// Rows 0..2 (through the card's header) are immutable history now.
|
||||
container.setNativeScrollbackCommittedRows(3);
|
||||
// The setter is a pure store: sealing waits for the next compose.
|
||||
expect(card.sealCount).toBe(0);
|
||||
container.render(W);
|
||||
expect(card.sealCount).toBe(1);
|
||||
expect(container.isBlockUncommitted(card)).toBe(false);
|
||||
// The seal pre-pass ran before the seam scan: the SAME render already
|
||||
// reports the seam unpinned (no still-mutating block left).
|
||||
expect(container.getNativeScrollbackLiveRegionStart()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not seal while the boundary stays above the block", () => {
|
||||
const { container, card } = cardAfterHistory();
|
||||
// Only "history" committed; the card's rows are all still retractable.
|
||||
container.setNativeScrollbackCommittedRows(1);
|
||||
container.render(W);
|
||||
expect(card.sealCount).toBe(0);
|
||||
expect(container.isBlockUncommitted(card)).toBe(true);
|
||||
});
|
||||
|
||||
it("never seals across same-value or decreasing republishes above the block", () => {
|
||||
const { container, card } = cardAfterHistory();
|
||||
// The engine republishes the committed count every frame (compose and
|
||||
// post-emit): repeated same-value and decreasing stores above the
|
||||
// card's rows never accumulate into a seal.
|
||||
container.setNativeScrollbackCommittedRows(1);
|
||||
container.render(W);
|
||||
container.setNativeScrollbackCommittedRows(1);
|
||||
container.render(W);
|
||||
container.setNativeScrollbackCommittedRows(0);
|
||||
container.render(W);
|
||||
expect(card.sealCount).toBe(0);
|
||||
expect(container.isBlockUncommitted(card)).toBe(true);
|
||||
});
|
||||
|
||||
it("seals exactly once as the boundary sweeps past the block in stages", () => {
|
||||
const container = new TranscriptContainer();
|
||||
container.addChild(new MutableBlock(["history"]));
|
||||
const card = new DisplaceableBlock(["todo-header", "todo-body"]);
|
||||
container.addChild(card);
|
||||
container.addChild(new MutableBlock(["tail"]));
|
||||
expect(container.render(W)).toEqual(["history", "", "todo-header", "todo-body", "", "tail"]);
|
||||
// First crossing (through the header) seals; the sealed block stops
|
||||
// reporting displaceable.
|
||||
container.setNativeScrollbackCommittedRows(3);
|
||||
container.render(W);
|
||||
expect(card.sealCount).toBe(1);
|
||||
// A later sweep past the whole block, and every subsequent render at
|
||||
// that boundary, must not seal again.
|
||||
container.setNativeScrollbackCommittedRows(6);
|
||||
container.render(W);
|
||||
container.render(W);
|
||||
expect(card.sealCount).toBe(1);
|
||||
});
|
||||
|
||||
it("never seals a displaceable block with an empty contribution", () => {
|
||||
const container = new TranscriptContainer();
|
||||
container.addChild(new MutableBlock(["history"]));
|
||||
const empty = new DisplaceableBlock([]);
|
||||
container.addChild(empty);
|
||||
container.addChild(new MutableBlock(["tail"]));
|
||||
expect(container.render(W)).toEqual(["history", "", "tail"]);
|
||||
// None of the block's rows are on the tape: nothing to seal, ever.
|
||||
container.setNativeScrollbackCommittedRows(3);
|
||||
container.render(W);
|
||||
expect(empty.sealCount).toBe(0);
|
||||
expect(container.isBlockUncommitted(empty)).toBe(true);
|
||||
});
|
||||
|
||||
it("walks past blocks without the displaceable protocol", () => {
|
||||
const container = new TranscriptContainer();
|
||||
const plain = new MutableBlock(["plain-block"]);
|
||||
container.addChild(plain);
|
||||
const card = new DisplaceableBlock(["todo-header"]);
|
||||
container.addChild(card);
|
||||
expect(container.render(W)).toEqual(["plain-block", "", "todo-header"]);
|
||||
container.setNativeScrollbackCommittedRows(3);
|
||||
// The pre-pass visits the plain block first (its rows also committed);
|
||||
// absent duck-typed methods are a no-op and the card below still seals.
|
||||
container.render(W);
|
||||
expect(card.sealCount).toBe(1);
|
||||
expect(container.isBlockUncommitted(plain)).toBe(false);
|
||||
});
|
||||
|
||||
it("seals a block that became displaceable after its rows committed", () => {
|
||||
// A pending tool's preview rows scroll into native scrollback before
|
||||
// its successful result arrives; only then does the block become a
|
||||
// displaceable snapshot. The walk runs every render, so the flip is
|
||||
// caught on the next compose — not only when the boundary moves.
|
||||
const { container, card } = cardAfterHistory(false);
|
||||
container.setNativeScrollbackCommittedRows(3);
|
||||
container.render(W);
|
||||
// Rows committed while not displaceable: nothing to seal yet.
|
||||
expect(card.sealCount).toBe(0);
|
||||
card.makeDisplaceable();
|
||||
container.render(W);
|
||||
expect(card.sealCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
+7
-2
@@ -40,6 +40,10 @@ function makeStreamingMessage(content: AssistantMessage["content"]): AssistantMe
|
||||
};
|
||||
}
|
||||
|
||||
// Components the controller mounts during a dispatch (pending tool previews).
|
||||
// Sealed in afterEach so their spinner intervals never outlive the test file.
|
||||
const mountedComponents: { seal?(): void }[] = [];
|
||||
|
||||
function createFixture(streamingMessage: AssistantMessage) {
|
||||
const markTranscriptBlockFinalized = vi.fn();
|
||||
const streamingComponent = {
|
||||
@@ -49,14 +53,14 @@ function createFixture(streamingMessage: AssistantMessage) {
|
||||
const ctx = {
|
||||
isInitialized: true,
|
||||
init: vi.fn(async () => {}),
|
||||
ui: { requestRender: vi.fn() },
|
||||
ui: { requestRender: vi.fn(), requestComponentRender: vi.fn() },
|
||||
statusLine: { invalidate: vi.fn() },
|
||||
updateEditorTopBorder: vi.fn(),
|
||||
streamingComponent,
|
||||
streamingMessage,
|
||||
pendingTools: new Map(),
|
||||
noteDisplayableThinkingContent: vi.fn(() => false),
|
||||
chatContainer: { addChild: vi.fn() },
|
||||
chatContainer: { addChild: vi.fn((child: { seal?(): void }) => mountedComponents.push(child)) },
|
||||
toolOutputExpanded: false,
|
||||
settings,
|
||||
session: { getToolByName: () => undefined },
|
||||
@@ -84,6 +88,7 @@ async function dispatchUpdate(message: AssistantMessage) {
|
||||
|
||||
describe("EventController finalizes assistant block when tool-call args stream", () => {
|
||||
afterEach(() => {
|
||||
for (const component of mountedComponents.splice(0)) component.seal?.();
|
||||
resetSettingsForTest();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -250,6 +250,45 @@ describe("ReadToolGroupComponent", () => {
|
||||
expect(extractLinkTexts(rendered)).not.toContain("src/example.ts:7-9");
|
||||
});
|
||||
|
||||
it("renders separate selector grouped summary paths while linking only the base path", () => {
|
||||
settings.override("tui.hyperlinks", "always");
|
||||
const component = new ReadToolGroupComponent();
|
||||
const resolvedPath = path.resolve("/workspace/src/grouped.ts");
|
||||
component.updateArgs({ path: "src/grouped.ts", selector: "2-3" }, "read-split-selector");
|
||||
component.updateResult(
|
||||
{
|
||||
content: [{ type: "text", text: "line 2" }],
|
||||
details: { meta: { source: { type: "path", value: resolvedPath } } },
|
||||
},
|
||||
false,
|
||||
"read-split-selector",
|
||||
);
|
||||
|
||||
const rendered = component.render(120).join("\n");
|
||||
|
||||
const groupedUri = new URL(url.pathToFileURL(path.resolve(resolvedPath)).href);
|
||||
groupedUri.searchParams.set("line", "2");
|
||||
expect(Bun.stripANSI(rendered)).toContain("Read src/grouped.ts:2-3");
|
||||
expect(extractLinkUris(rendered)).toContain(groupedUri.href);
|
||||
expect(extractLinkTexts(rendered)).toContain("src/grouped.ts");
|
||||
expect(extractLinkTexts(rendered)).not.toContain("src/grouped.ts:2-3");
|
||||
});
|
||||
|
||||
it("ignores non-string selectors from malformed runtime args", () => {
|
||||
const component = new ReadToolGroupComponent();
|
||||
const malformedArgs = { path: "src/example.ts", selector: 10 } as unknown as {
|
||||
path: string;
|
||||
selector: string;
|
||||
};
|
||||
|
||||
expect(() => component.updateArgs(malformedArgs, "read-malformed-selector")).not.toThrow();
|
||||
|
||||
const plain = Bun.stripANSI(component.render(120).join("\n"));
|
||||
|
||||
expect(plain).toContain("Read src/example.ts");
|
||||
expect(plain).not.toContain("src/example.ts:10");
|
||||
});
|
||||
|
||||
it("links inline preview titles when the summary row is suppressed", () => {
|
||||
settings.override("tui.hyperlinks", "always");
|
||||
const component = new ReadToolGroupComponent({ showContentPreview: true });
|
||||
|
||||
@@ -16,6 +16,7 @@ describe("tryRunRpcSkillCommand", () => {
|
||||
);
|
||||
|
||||
let message: Pick<CustomMessage, "attribution" | "content" | "customType" | "details" | "display"> | undefined;
|
||||
let options: { streamingBehavior?: "steer" | "followUp" } | undefined;
|
||||
|
||||
const handled = await tryRunRpcSkillCommand(
|
||||
{
|
||||
@@ -23,8 +24,9 @@ describe("tryRunRpcSkillCommand", () => {
|
||||
skills: [
|
||||
{ name: "reviewer", description: "Review code", filePath: skillPath, baseDir: dir, source: "project" },
|
||||
],
|
||||
async promptCustomMessage(nextMessage: typeof message) {
|
||||
async promptCustomMessage(nextMessage: typeof message, nextOptions?: typeof options) {
|
||||
message = nextMessage;
|
||||
options = nextOptions;
|
||||
},
|
||||
},
|
||||
"/skill:reviewer focus on risks",
|
||||
@@ -39,10 +41,49 @@ describe("tryRunRpcSkillCommand", () => {
|
||||
expect(message?.content).toContain("User: focus on risks");
|
||||
expect(message?.display).toBe(true);
|
||||
expect(message?.attribution).toBe("user");
|
||||
expect(options).toEqual({ streamingBehavior: "steer" });
|
||||
|
||||
await removeWithRetries(dir);
|
||||
});
|
||||
|
||||
test("honors the RPC prompt streaming behavior for registered /skill commands", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), `omp-rpc-skill-${Snowflake.next()}-`));
|
||||
const skillPath = path.join(dir, "SKILL.md");
|
||||
await Bun.write(
|
||||
skillPath,
|
||||
"---\nname: reviewer\ndescription: Review code\n---\n\nReview the supplied code carefully.\n",
|
||||
);
|
||||
|
||||
let options: { streamingBehavior?: "steer" | "followUp" } | undefined;
|
||||
try {
|
||||
const handled = await tryRunRpcSkillCommand(
|
||||
{
|
||||
skillsSettings: { enableSkillCommands: true },
|
||||
skills: [
|
||||
{
|
||||
name: "reviewer",
|
||||
description: "Review code",
|
||||
filePath: skillPath,
|
||||
baseDir: dir,
|
||||
source: "project",
|
||||
},
|
||||
],
|
||||
async promptCustomMessage(nextMessage, nextOptions) {
|
||||
expect(nextMessage.customType).toBe(SKILL_PROMPT_MESSAGE_TYPE);
|
||||
options = nextOptions;
|
||||
},
|
||||
},
|
||||
"/skill:reviewer wait for the current turn",
|
||||
"followUp",
|
||||
);
|
||||
|
||||
expect(handled).toEqual({ agentInvoked: true });
|
||||
expect(options?.streamingBehavior).toBe("followUp");
|
||||
} finally {
|
||||
await removeWithRetries(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test("ignores unknown skill commands so normal prompt handling can continue", async () => {
|
||||
const handled = await tryRunRpcSkillCommand(
|
||||
{
|
||||
|
||||
@@ -70,6 +70,51 @@ describe("Settings", () => {
|
||||
await Bun.sleep(0);
|
||||
await tempDir?.remove();
|
||||
});
|
||||
|
||||
describe("main config file selection", () => {
|
||||
it("loads and updates an existing config.yaml without creating config.yml", async () => {
|
||||
const yamlConfigPath = path.join(agentDir, "config.yaml");
|
||||
await Bun.write(yamlConfigPath, YAML.stringify({ setupVersion: 1 }, null, 2));
|
||||
|
||||
const settings = await Settings.init({ cwd: projectDir, agentDir });
|
||||
expect(settings.get("setupVersion")).toBe(1);
|
||||
|
||||
settings.set("setupVersion", 2);
|
||||
await settings.flush();
|
||||
|
||||
const savedSettings = YAML.parse(await Bun.file(yamlConfigPath).text()) as Record<string, unknown>;
|
||||
expect(savedSettings.setupVersion).toBe(2);
|
||||
expect(await Bun.file(getConfigPath()).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("clones the selected config.yaml path for persisted settings", async () => {
|
||||
const yamlConfigPath = path.join(agentDir, "config.yaml");
|
||||
await Bun.write(yamlConfigPath, YAML.stringify({ setupVersion: 1 }, null, 2));
|
||||
|
||||
const settings = await Settings.init({ cwd: projectDir, agentDir });
|
||||
const cloned = await settings.cloneForCwd(tempDir.join("other-project"));
|
||||
|
||||
cloned.set("setupVersion", 2);
|
||||
await cloned.flush();
|
||||
|
||||
const savedSettings = YAML.parse(await Bun.file(yamlConfigPath).text()) as Record<string, unknown>;
|
||||
expect(savedSettings.setupVersion).toBe(2);
|
||||
expect(await Bun.file(getConfigPath()).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("creates config.yml for new persisted settings when no main config exists", async () => {
|
||||
const yamlConfigPath = path.join(agentDir, "config.yaml");
|
||||
|
||||
const settings = await Settings.init({ cwd: projectDir, agentDir });
|
||||
settings.set("setupVersion", 1);
|
||||
await settings.flush();
|
||||
|
||||
expect(await Bun.file(getConfigPath()).exists()).toBe(true);
|
||||
expect(await Bun.file(yamlConfigPath).exists()).toBe(false);
|
||||
expect((await readSettings()).setupVersion).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaults", () => {
|
||||
it("keeps eight inline images live by default", async () => {
|
||||
const settings = await Settings.init({ cwd: projectDir, agentDir });
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
|
||||
import { ToolExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tool-execution";
|
||||
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import { type Component, TUI } from "@oh-my-pi/pi-tui";
|
||||
import { StressRenderScheduler } from "../../tui/test/render-stress-scheduler";
|
||||
import { VirtualTerminal } from "../../tui/test/virtual-terminal";
|
||||
|
||||
function writeArgs(lineCount: number) {
|
||||
return {
|
||||
path: "notes.txt",
|
||||
content: Array.from({ length: lineCount }, (_, i) => `line ${i + 1}`).join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
function partialWriteResult(text = "Writing notes.txt...") {
|
||||
return { content: [{ type: "text", text }] };
|
||||
}
|
||||
|
||||
class Footer implements Component {
|
||||
constructor(readonly rows: number) {}
|
||||
invalidate(): void {}
|
||||
render(_width: number): string[] {
|
||||
return Array.from({ length: this.rows }, (_, i) => `editor-${i}`);
|
||||
}
|
||||
}
|
||||
|
||||
function plainBuffer(term: VirtualTerminal): string[] {
|
||||
return term
|
||||
.getScrollBuffer()
|
||||
.map(row => Bun.stripANSI(row).trimEnd())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
describe("ToolExecutionComponent write repaint seam", () => {
|
||||
const components: ToolExecutionComponent[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
await initTheme();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const component of components) component.stopAnimation();
|
||||
components.length = 0;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function makeComponent(args: unknown) {
|
||||
const resetDisplay = vi.fn();
|
||||
const ui = { requestRender() {}, requestComponentRender() {}, resetDisplay } as unknown as TUI;
|
||||
const component = new ToolExecutionComponent("write", args, {}, undefined, ui);
|
||||
components.push(component);
|
||||
resetDisplay.mockClear();
|
||||
return { component, resetDisplay };
|
||||
}
|
||||
|
||||
it("forces a viewport repaint when a painted collapsed tail window receives its first result", () => {
|
||||
// 20 lines > WRITE_STREAMING_PREVIEW_LINES (12): the pending preview is a
|
||||
// tail window the first-result render re-anchors to the top of the file.
|
||||
const { component, resetDisplay } = makeComponent(writeArgs(20));
|
||||
component.render(80);
|
||||
|
||||
component.updateResult(partialWriteResult(), true);
|
||||
|
||||
expect(resetDisplay).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not repaint when the pending tail window never reaches the terminal", () => {
|
||||
const { component, resetDisplay } = makeComponent(writeArgs(20));
|
||||
// No render() before the result: a resetDisplay here would wipe native
|
||||
// scrollback for a shape the user never saw.
|
||||
|
||||
component.updateResult(partialWriteResult(), true);
|
||||
|
||||
expect(resetDisplay).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not repaint a collapsed preview that fits the streaming window", () => {
|
||||
// 12 lines render top-anchored without a tail window, so the first result
|
||||
// does not re-anchor the frame; wiping scrollback would be gratuitous.
|
||||
const { component, resetDisplay } = makeComponent(writeArgs(12));
|
||||
component.render(80);
|
||||
|
||||
component.updateResult(partialWriteResult(), true);
|
||||
|
||||
expect(resetDisplay).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not repaint an expanded pending preview", () => {
|
||||
// Expanded previews show the whole file top-anchored — no tail window to
|
||||
// re-anchor.
|
||||
const { component, resetDisplay } = makeComponent(writeArgs(20));
|
||||
component.setExpanded(true);
|
||||
component.render(80);
|
||||
|
||||
component.updateResult(partialWriteResult(), true);
|
||||
|
||||
expect(resetDisplay).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes stale pending tail rows from the terminal buffer when the first partial result arrives", async () => {
|
||||
const term = new VirtualTerminal(80, 8, 1_000);
|
||||
const scheduler = new StressRenderScheduler();
|
||||
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
|
||||
const component = new ToolExecutionComponent("write", writeArgs(20), {}, undefined, tui);
|
||||
components.push(component);
|
||||
tui.addChild(component);
|
||||
tui.addChild(new Footer(5));
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await scheduler.drain(term);
|
||||
const pendingRows = plainBuffer(term);
|
||||
expect(pendingRows.some(row => row.includes("… (8 earlier lines)"))).toBe(true);
|
||||
expect(pendingRows.some(row => row.includes("… (streaming)"))).toBe(true);
|
||||
expect(pendingRows.some(row => row.includes("20 line 20"))).toBe(true);
|
||||
|
||||
component.setArgsComplete();
|
||||
tui.requestRender();
|
||||
await scheduler.drain(term);
|
||||
|
||||
component.updateResult(partialWriteResult(), true);
|
||||
tui.requestRender();
|
||||
await scheduler.drain(term);
|
||||
|
||||
const rows = plainBuffer(term);
|
||||
// The stale pending tail window must not survive above the new frame.
|
||||
expect(rows.some(row => row.includes("… (streaming)"))).toBe(false);
|
||||
expect(rows.some(row => row.includes("earlier lines"))).toBe(false);
|
||||
expect(rows.some(row => row.includes("20 line 20"))).toBe(false);
|
||||
// The first partial-result frame is what remains: progress line plus the
|
||||
// top-anchored preview.
|
||||
expect(rows.some(row => row.includes("Writing notes.txt..."))).toBe(true);
|
||||
expect(rows.some(row => row.includes(" 1 line 1"))).toBe(true);
|
||||
expect(rows.some(row => row.includes("… 14 more lines"))).toBe(true);
|
||||
} finally {
|
||||
tui.stop();
|
||||
await term.flush();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -330,6 +330,34 @@ describe("Coding Agent Tools", () => {
|
||||
expect(result.details?.truncation).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats empty optional selector as omitted for read", async () => {
|
||||
const testFile = path.join(testDir, "read-empty-selector.txt");
|
||||
const content = "alpha\nselector target\nomega";
|
||||
fs.writeFileSync(testFile, content);
|
||||
|
||||
const omitted = getTextOutput(await readTool.execute("test-read-empty-selector-omitted", { path: testFile }));
|
||||
expect(omitted).toContain("alpha");
|
||||
expect(omitted).toContain("selector target");
|
||||
expect(omitted).toContain("omega");
|
||||
|
||||
for (const { name, selector } of [
|
||||
{ name: "empty", selector: "" },
|
||||
{ name: "whitespace", selector: " \t\n " },
|
||||
]) {
|
||||
const withOptionalSelector = getTextOutput(
|
||||
await readTool.execute(`test-read-empty-selector-${name}`, {
|
||||
path: testFile,
|
||||
selector,
|
||||
}),
|
||||
);
|
||||
expect(withOptionalSelector).toBe(omitted);
|
||||
}
|
||||
|
||||
await expect(
|
||||
readTool.execute("test-read-empty-selector-malformed", { path: testFile, selector: "-100" }),
|
||||
).rejects.toThrow(/Invalid selector/);
|
||||
});
|
||||
|
||||
it("truncates lines wider than the read column cap, leaving narrow lines untouched", async () => {
|
||||
const wideLine = "x".repeat(1500);
|
||||
const testFile = path.join(testDir, "wide.txt");
|
||||
@@ -1649,6 +1677,42 @@ function b() {
|
||||
expect(output).toMatch(/\*2\|match line/);
|
||||
});
|
||||
|
||||
it("treats empty optional selector as omitted for search", async () => {
|
||||
const testFile = path.join(testDir, "grep-empty-selector.txt");
|
||||
fs.writeFileSync(testFile, "before\nneedle empty selector\nbetween\nneedle whitespace selector\nafter");
|
||||
|
||||
const omitted = getTextOutput(
|
||||
await searchTool.execute("test-search-empty-selector-omitted", {
|
||||
pattern: "needle",
|
||||
path: testFile,
|
||||
}),
|
||||
);
|
||||
expect(omitted).toMatch(/\*2\|needle empty selector/);
|
||||
expect(omitted).toMatch(/\*4\|needle whitespace selector/);
|
||||
|
||||
for (const { name, selector } of [
|
||||
{ name: "empty", selector: "" },
|
||||
{ name: "whitespace", selector: " \t\n " },
|
||||
]) {
|
||||
const withOptionalSelector = getTextOutput(
|
||||
await searchTool.execute(`test-search-empty-selector-${name}`, {
|
||||
pattern: "needle",
|
||||
path: testFile,
|
||||
selector,
|
||||
}),
|
||||
);
|
||||
expect(withOptionalSelector).toBe(omitted);
|
||||
}
|
||||
|
||||
await expect(
|
||||
searchTool.execute("test-search-empty-selector-malformed", {
|
||||
pattern: "needle",
|
||||
path: testFile,
|
||||
selector: "not-a-range",
|
||||
}),
|
||||
).rejects.toThrow(/selector "not-a-range" is invalid/);
|
||||
});
|
||||
|
||||
it("flags a zero-match search as contextually useless", async () => {
|
||||
fs.writeFileSync(path.join(testDir, "plain.txt"), "nothing interesting here\n");
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user