diff --git a/.config/jp/tools/src/bash_tests.rs b/.config/jp/tools/src/bash_tests.rs index 9f1fa2799..c156d1e34 100644 --- a/.config/jp/tools/src/bash_tests.rs +++ b/.config/jp/tools/src/bash_tests.rs @@ -563,78 +563,3 @@ fn a_successful_run_still_reports_its_exit_code() { 0\n\n```" ); } - -/// The install script's whole point: a base image without python gets it, and -/// the built image is what the commands then run in. -/// -/// Also covers the `USER` round-trip — the script installs as root, and -/// `python3 -c` runs as `nonroot` afterwards, which only works if the restore -/// resolved to a user the base image actually defines. -/// -/// Needs a container runtime and network access for the package install. -/// The first run builds; later runs hit the cached tag and take about as long -/// as any other call. -#[test] -#[ignore = "builds a real image"] -fn an_install_script_adds_a_tool_the_base_image_lacks() { - let dir = workspace(); - let plan = Plan { - image: DEFAULT_IMAGE.to_owned(), - install: Some(Install { - script: "apk add --no-cache python3".to_owned(), - run_as: DEFAULT_RUN_AS.to_owned(), - }), - mounts: vec![], - envs: vec![], - script: "set -euo pipefail\npython3 -c 'print(1 + 1)'\nid -un\n".to_owned(), - }; - let runtime = detect().expect("a container runtime must be installed"); - - let content = execute(dir.path(), runtime, &plan, &DuctProcessRunner) - .unwrap() - .unwrap_content(); - - assert_eq!( - content, - "```xml\n\n 2\nnonroot\n \ - 0\n\n```" - ); -} - -/// Read-only mounts are what keep this tool from being able to replace the -/// workspace-editing tools, so it is worth proving against a real runtime: a -/// runtime that ignored `:ro` would drop the guarantee silently. -/// -/// Runs against [`DEFAULT_IMAGE`], so it also covers the two things about an -/// image that this tool depends on: that `bash` is present, and that no -/// `ENTRYPOINT` swallows the `bash -c` invocation. -/// -/// Needs a container runtime that is allowed to share the temp directory -/// (Docker Desktop restricts which host paths it will bind-mount). -#[test] -#[ignore = "starts a real container"] -fn a_mounted_path_is_read_only_in_the_container() { - let dir = workspace(); - let plan = Plan { - image: DEFAULT_IMAGE.to_owned(), - install: None, - mounts: vec![( - dir.path().join("crates").canonicalize_utf8().unwrap(), - "/workspace/crates".to_owned(), - )], - envs: vec![], - script: "set -euo pipefail\necho written > /workspace/crates/lib.rs\n".to_owned(), - }; - let runtime = detect().expect("a container runtime must be installed"); - - let content = execute(dir.path(), runtime, &plan, &DuctProcessRunner) - .unwrap() - .unwrap_content(); - - // Under `set -e` bash exits 1 when the redirect cannot open the file. - assert!(content.contains("1"), "got: {content}"); - assert_eq!( - std::fs::read_to_string(dir.path().join("crates/lib.rs")).unwrap(), - "" - ); -} diff --git a/.config/supply-chain/audits.toml b/.config/supply-chain/audits.toml index 1cf4ca9fa..7a7607696 100644 --- a/.config/supply-chain/audits.toml +++ b/.config/supply-chain/audits.toml @@ -26,6 +26,11 @@ who = "Jean Mertz " criteria = "safe-to-deploy" version = "0.7.3" +[[audits.chacha20]] +who = "Jean Mertz " +criteria = "safe-to-deploy" +delta = "0.10.0 -> 0.10.2" + [[audits.comfy-table]] who = "Jean Mertz " criteria = "safe-to-deploy" @@ -46,6 +51,11 @@ who = "Jean Mertz " criteria = "safe-to-deploy" delta = "0.2.14 -> 0.3.5" +[[audits.cpufeatures]] +who = "Jean Mertz " +criteria = "safe-to-deploy" +delta = "0.3.0 -> 0.3.1" + [[audits.datetime_literal]] who = "Jean Mertz " criteria = "safe-to-deploy" @@ -81,6 +91,11 @@ who = "Jean Mertz " criteria = "safe-to-deploy" version = "0.1.5" +[[audits.getrandom]] +who = "Jean Mertz " +criteria = "safe-to-deploy" +delta = "0.4.2 -> 0.4.3" + [[audits.hashlink]] who = "Jean Mertz " criteria = "safe-to-deploy" @@ -196,6 +211,11 @@ who = "Jean Mertz " criteria = "safe-to-deploy" delta = "0.39.2 -> 0.41.0" +[[audits.r-efi]] +who = "Jean Mertz " +criteria = "safe-to-deploy" +delta = "5.3.0 -> 6.0.0" + [[audits.ra-ap-rustc_lexer]] who = "Jean Mertz " criteria = "safe-to-deploy" @@ -206,6 +226,16 @@ who = "Jean Mertz " criteria = "safe-to-deploy" delta = "0.9.4 -> 0.9.5" +[[audits.rand]] +who = "Jean Mertz " +criteria = "safe-to-deploy" +delta = "0.10.1 -> 0.10.2" + +[[audits.rand_core]] +who = "Jean Mertz " +criteria = "safe-to-deploy" +delta = "0.10.0 -> 0.10.1" + [[audits.rand_xorshift]] who = "Jean Mertz " criteria = "safe-to-deploy" @@ -261,6 +291,11 @@ who = "Jean Mertz " criteria = "safe-to-deploy" delta = "0.23.35 -> 0.23.37" +[[audits.rustls]] +who = "Jean Mertz " +criteria = "safe-to-deploy" +delta = "0.23.35 -> 0.23.45" + [[audits.rustls-webpki]] who = "Jean Mertz " criteria = "safe-to-deploy" @@ -271,6 +306,11 @@ who = "Jean Mertz " criteria = "safe-to-deploy" delta = "0.103.10 -> 0.103.12" +[[audits.rustls-webpki]] +who = "Jean Mertz " +criteria = "safe-to-deploy" +delta = "0.103.13 -> 0.103.15" + [[audits.ruzstd]] who = "Jean Mertz " criteria = "safe-to-deploy" @@ -321,6 +361,11 @@ who = "Jean Mertz " criteria = "safe-to-deploy" version = "0.5.2" +[[audits.sse-stream]] +who = "Jean Mertz " +criteria = "safe-to-deploy" +version = "0.2.6" + [[audits.string_cache]] who = "Jean Mertz " criteria = "safe-to-deploy" @@ -533,6 +578,12 @@ trusted-publisher = "github:rust-lang/cc-rs" start = "2025-09-01" end = "2027-03-04" +[[trusted.chacha20]] +criteria = "safe-to-deploy" +trusted-publisher = "github:RustCrypto/stream-ciphers" +start = "2026-02-06" +end = "2027-09-23" + [[trusted.clap]] criteria = "safe-to-deploy" user-id = 6743 # Ed Page (epage) @@ -575,6 +626,12 @@ user-id = 696 # Nick Fitzgerald (fitzgen) start = "2019-07-30" end = "2027-08-19" +[[trusted.cpufeatures]] +criteria = "safe-to-deploy" +user-id = 267 # Tony Arcieri (tarcieri) +start = "2021-04-26" +end = "2027-09-23" + [[trusted.dtoa]] criteria = "safe-to-deploy" user-id = 3618 # David Tolnay (dtolnay) @@ -923,6 +980,18 @@ user-id = 3618 # David Tolnay (dtolnay) start = "2019-04-09" end = "2027-02-13" +[[trusted.rand]] +criteria = "safe-to-deploy" +trusted-publisher = "github:rust-random/rand" +start = "2026-01-26" +end = "2027-09-23" + +[[trusted.rand_core]] +criteria = "safe-to-deploy" +trusted-publisher = "github:rust-random/rand_core" +start = "2026-01-20" +end = "2027-09-23" + [[trusted.ref-cast]] criteria = "safe-to-deploy" user-id = 3618 # David Tolnay (dtolnay) diff --git a/.config/supply-chain/config.toml b/.config/supply-chain/config.toml index e7b654e1e..30e3ecc37 100644 --- a/.config/supply-chain/config.toml +++ b/.config/supply-chain/config.toml @@ -153,10 +153,6 @@ criteria = "safe-to-deploy" version = "0.10.1" criteria = "safe-to-deploy" -[[exemptions.cpufeatures]] -version = "0.2.17" -criteria = "safe-to-deploy" - [[exemptions.crc32fast]] version = "1.5.0" criteria = "safe-to-deploy" diff --git a/.config/supply-chain/imports.lock b/.config/supply-chain/imports.lock index 07d124a3b..f1457de64 100644 --- a/.config/supply-chain/imports.lock +++ b/.config/supply-chain/imports.lock @@ -130,6 +130,11 @@ version = "1.2.56" when = "2026-02-13" trusted-publisher = "github:rust-lang/cc-rs" +[[publisher.chacha20]] +version = "0.10.2" +when = "2026-08-27" +trusted-publisher = "github:RustCrypto/stream-ciphers" + [[publisher.clap]] version = "4.5.48" when = "2025-09-19" @@ -179,6 +184,20 @@ user-id = 696 user-login = "fitzgen" user-name = "Nick Fitzgerald" +[[publisher.cpufeatures]] +version = "0.2.17" +when = "2025-01-25" +user-id = 267 +user-login = "tarcieri" +user-name = "Tony Arcieri" + +[[publisher.cpufeatures]] +version = "0.3.1" +when = "2026-08-26" +user-id = 267 +user-login = "tarcieri" +user-name = "Tony Arcieri" + [[publisher.dtoa]] version = "1.0.11" when = "2025-12-27" @@ -552,6 +571,16 @@ user-id = 3618 user-login = "dtolnay" user-name = "David Tolnay" +[[publisher.rand]] +version = "0.10.2" +when = "2026-07-02" +trusted-publisher = "github:rust-random/rand" + +[[publisher.rand_core]] +version = "0.10.1" +when = "2026-04-13" +trusted-publisher = "github:rust-random/rand_core" + [[publisher.ref-cast]] version = "1.0.24" when = "2025-03-03" @@ -651,8 +680,8 @@ user-login = "dtolnay" user-name = "David Tolnay" [[publisher.serde_json]] -version = "1.0.149" -when = "2026-01-06" +version = "1.0.151" +when = "2026-07-20" user-id = 3618 user-login = "dtolnay" user-name = "David Tolnay" @@ -1425,6 +1454,12 @@ who = "Pat Hickey " criteria = "safe-to-deploy" delta = "0.3.28 -> 0.3.31" +[[audits.bytecode-alliance.audits.getrandom]] +who = "Alex Crichton " +criteria = "safe-to-deploy" +delta = "0.4.1 -> 0.4.2" +notes = "Nothing awry in this update, standard updates for some platforms and other misc things." + [[audits.bytecode-alliance.audits.gimli]] who = "Alex Crichton " criteria = "safe-to-deploy" @@ -1599,28 +1634,12 @@ who = "Alex Crichton " criteria = "safe-to-deploy" delta = "0.1.21 -> 0.1.24" -[[audits.bytecode-alliance.audits.rustls]] -who = "Alex Crichton " -criteria = "safe-to-deploy" -delta = "0.23.37 -> 0.23.45" -notes = """ -A relatively large update, but no new `unsafe` and nothing awry here. Lots of -protocol/etc updates which I'm not personally an expert within but the rustls -maintainers are relatively well trusted as well. -""" - [[audits.bytecode-alliance.audits.rustls-webpki]] who = "Alex Crichton " criteria = "safe-to-deploy" delta = "0.103.12 -> 0.103.13" notes = "Minor fixes for the bug being fixed in this release, nothing awry." -[[audits.bytecode-alliance.audits.rustls-webpki]] -who = "Alex Crichton " -criteria = "safe-to-deploy" -delta = "0.103.13 -> 0.103.15" -notes = "Minor updates and feature shufflings." - [[audits.bytecode-alliance.audits.sha1]] who = "Andrew Brown " criteria = "safe-to-deploy" @@ -2732,6 +2751,16 @@ who = "J.C. Jones " criteria = "safe-to-deploy" delta = "1.0.1 -> 1.0.3" +[[audits.isrg.audits.getrandom]] +who = "David Cook " +criteria = "safe-to-deploy" +delta = "0.3.4 -> 0.4.0" + +[[audits.isrg.audits.getrandom]] +who = "David Cook " +criteria = "safe-to-deploy" +delta = "0.4.0 -> 0.4.1" + [[audits.isrg.audits.libbz2-rs-sys]] who = "Ameer Ghani " criteria = "safe-to-deploy" diff --git a/Cargo.lock b/Cargo.lock index 83292fbdd..ca4ed6bb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -564,6 +564,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.42" @@ -772,6 +783,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1490,11 +1510,23 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + [[package]] name = "gimli" version = "0.31.1" @@ -2146,7 +2178,6 @@ dependencies = [ "camino", "dyn-clone", "dyn-hash", - "jp_mcp", "linkme", "percent-encoding", "serde", @@ -2163,7 +2194,6 @@ dependencies = [ "duct", "indoc", "jp_attachment", - "jp_mcp", "serde", "serde_json", "tokio", @@ -2180,7 +2210,6 @@ dependencies = [ "grizzly", "indoc", "jp_attachment", - "jp_mcp", "quick-xml", "serde", "test-log", @@ -2196,10 +2225,8 @@ dependencies = [ "camino", "camino-tempfile", "duct", - "indexmap", "indoc", "jp_attachment", - "jp_mcp", "quick-xml", "serde", "shlex", @@ -2218,10 +2245,8 @@ dependencies = [ "crossbeam-channel", "glob", "ignore", - "indexmap", "infer", "jp_attachment", - "jp_mcp", "serde", "test-log", "tokio", @@ -2238,7 +2263,6 @@ dependencies = [ "glob", "jp_attachment", "jp_github", - "jp_mcp", "serde", "tracing", "url", @@ -2252,7 +2276,6 @@ dependencies = [ "camino", "htmd", "jp_attachment", - "jp_mcp", "reqwest", "serde", "tracing", @@ -2285,9 +2308,8 @@ dependencies = [ "async-trait", "camino", "jp_attachment", - "jp_mcp", - "quick-xml", "serde", + "tokio", "url", ] @@ -2348,10 +2370,12 @@ dependencies = [ "minijinja", "pretty_assertions", "quick-xml", + "rand 0.9.5", "rayon", "regex", "relative-path", "reqwest", + "rmcp", "schemars", "schematic", "serde", @@ -2539,6 +2563,7 @@ dependencies = [ "async-trait", "base64", "camino", + "camino-tempfile", "chrono", "datetime_literal", "eventsource-stream", @@ -2553,17 +2578,16 @@ dependencies = [ "jp_config", "jp_conversation", "jp_credentials", - "jp_mcp", "jp_openrouter", "jp_storage", "jp_test", "jp_tool", - "minijinja", "ollama-rs", "openai_responses", "paste", + "process-wrap", "quick-xml", - "rand", + "rand 0.9.5", "reqwest", "reqwest-eventsource", "saphyr", @@ -2573,8 +2597,8 @@ dependencies = [ "test-log", "thiserror 2.0.20", "tokio", - "tokio-util", "tracing", + "tracing-subscriber", "url", "uuid", ] @@ -2587,16 +2611,29 @@ version = "0.1.0" name = "jp_mcp" version = "0.1.0" dependencies = [ + "assert_matches", + "async-trait", + "axum", + "base64", + "camino", + "camino-tempfile", + "futures", "indexmap", "jp_config", + "jp_tool", + "minijinja", + "reqwest", "rmcp", "serde", "serde_json", "sha1", "sha2", + "sse-stream", "thiserror 2.0.20", "tokio", + "tokio-util", "tracing", + "url", "which", ] @@ -2747,6 +2784,7 @@ version = "0.1.0" dependencies = [ "camino", "camino-tempfile", + "indexmap", "serde", "serde_json", "thiserror 2.0.20", @@ -3498,7 +3536,7 @@ checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40" dependencies = [ "bitflags 2.9.4", "num-traits", - "rand", + "rand 0.9.5", "rand_chacha", "rand_xorshift", "regex-syntax", @@ -3545,7 +3583,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.5", "ring", "rustc-hash 2.1.1", "rustls", @@ -3586,6 +3624,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "ra-ap-rustc_lexer" version = "0.167.0" @@ -3604,7 +3648,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.3", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -3614,7 +3669,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.3", ] [[package]] @@ -3626,13 +3681,19 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_xorshift" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core", + "rand_core 0.9.3", ] [[package]] @@ -3845,20 +3906,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" dependencies = [ "async-trait", + "bytes", "chrono", "futures", + "http", + "http-body", + "http-body-util", "pastey", "pin-project-lite", "process-wrap", + "rand 0.10.2", "rmcp-macros", "schemars", "serde", "serde_json", + "sse-stream", "thiserror 2.0.20", "tokio", "tokio-stream", "tokio-util", + "tower-service", "tracing", + "uuid", ] [[package]] @@ -4070,7 +4139,6 @@ dependencies = [ "schemars_derive", "serde", "serde_json", - "url", ] [[package]] @@ -4281,9 +4349,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "indexmap", "itoa", @@ -4409,7 +4477,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4426,7 +4494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4557,6 +4625,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "sse-stream" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -5346,7 +5427,9 @@ version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ + "getrandom 0.4.3", "js-sys", + "serde_core", "sha1_smol", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index 3d42c3358..e8da91626 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -114,6 +114,7 @@ percent-encoding = { version = "2", default-features = false } portable-pty = { version = "0.9", default-features = false } pretty_assertions = { version = "1", default-features = false } proc-macro2 = { version = "1", default-features = false } +process-wrap = { version = "9", default-features = false } proptest = { version = "1", default-features = false } quick-xml = { version = "0.41", default-features = false } quote = { version = "1", default-features = false } @@ -131,7 +132,7 @@ rowan = { version = "0.16", default-features = false } rusqlite = { version = "0.38", default-features = false } rustc-demangle = { version = "0.1", default-features = false } saphyr = { git = "https://github.com/JeanMertz/saphyr", branch = "jean/fix-multinewline-endings", default-features = false } # -schemars = { version = "1.0.0-alpha.17", default-features = false } +schemars = { version = "1", default-features = false } scraper = { version = "0.25", default-features = false } secrecy = { version = "0.10", default-features = false } semver = { version = "1", default-features = false } @@ -147,6 +148,7 @@ sha1 = { version = "0.10", default-features = false } sha2 = { version = "0.10", default-features = false } shlex = { version = "1", default-features = false } similar = { version = "2", default-features = false } +sse-stream = { version = "0.2", default-features = false } strip-ansi-escapes = { version = "0.2", default-features = false } syn = { version = "2", default-features = false } syntect = { version = "5.3", default-features = false } diff --git a/crates/contrib/async-anthropic/src/types.rs b/crates/contrib/async-anthropic/src/types.rs index 6852dc4d4..6dc0a520c 100644 --- a/crates/contrib/async-anthropic/src/types.rs +++ b/crates/contrib/async-anthropic/src/types.rs @@ -12,8 +12,30 @@ use crate::messages; #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] pub struct Usage { + /// Uncached input tokens; cache reads and writes are reported separately. pub input_tokens: Option, + /// Generated output tokens. pub output_tokens: Option, + /// Input tokens written to the prompt cache. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_creation_input_tokens: Option, + /// Input tokens read from the prompt cache. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_read_input_tokens: Option, + /// Cache writes classified by retention period, when reported. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_creation: Option, +} + +/// Prompt-cache writes classified by retention period. +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] +pub struct CacheCreationUsage { + /// Tokens written with a five-minute retention period. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ephemeral_5m_input_tokens: Option, + /// Tokens written with a one-hour retention period. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ephemeral_1h_input_tokens: Option, } #[derive(Clone, Debug, Deserialize)] diff --git a/crates/contrib/bookworm/Cargo.toml b/crates/contrib/bookworm/Cargo.toml index 0665e2532..18eb282f6 100644 --- a/crates/contrib/bookworm/Cargo.toml +++ b/crates/contrib/bookworm/Cargo.toml @@ -18,7 +18,7 @@ indoc = { workspace = true } reqwest = { workspace = true, features = ["json", "rustls-tls"] } rmcp = { workspace = true, features = ["server", "transport-io", "macros"] } rusqlite = { workspace = true, features = ["bundled", "array", "vtab"] } -schemars = { workspace = true, features = ["preserve_order", "schemars_derive", "std", "url2"] } +schemars = { workspace = true, features = ["derive", "preserve_order", "std"] } scraper = { workspace = true } semver = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/crates/jp_attachment/Cargo.toml b/crates/jp_attachment/Cargo.toml index e787c8aa4..19337aaf5 100644 --- a/crates/jp_attachment/Cargo.toml +++ b/crates/jp_attachment/Cargo.toml @@ -13,8 +13,6 @@ repository.workspace = true version.workspace = true [dependencies] -jp_mcp = { workspace = true } - async-trait = { workspace = true } camino = { workspace = true } dyn-clone = { workspace = true } diff --git a/crates/jp_attachment/src/lib.rs b/crates/jp_attachment/src/lib.rs index 8f3365614..983e32b02 100644 --- a/crates/jp_attachment/src/lib.rs +++ b/crates/jp_attachment/src/lib.rs @@ -8,7 +8,6 @@ use async_trait::async_trait; use camino::Utf8Path; use dyn_clone::DynClone; use dyn_hash::DynHash; -use jp_mcp::Client; pub use linkme::{self, distributed_slice}; use serde::{Deserialize, Serialize}; pub use typetag; @@ -139,14 +138,7 @@ pub trait Handler: std::fmt::Debug + DynClone + DynHash + Send + Sync { /// /// The `cwd` parameter is the current working directory, and can be used to /// resolve relative paths. - /// - /// The `mcp_client` parameter is the MCP client to use for fetching - /// resources from MCP servers, if needed. - async fn get( - &self, - cwd: &Utf8Path, - mcp_client: Client, - ) -> Result, Box>; + async fn get(&self, cwd: &Utf8Path) -> Result, Box>; } dyn_clone::clone_trait_object!(Handler); diff --git a/crates/jp_attachment_agentic_shepherd/Cargo.toml b/crates/jp_attachment_agentic_shepherd/Cargo.toml index 60a55e451..3dffde60f 100644 --- a/crates/jp_attachment_agentic_shepherd/Cargo.toml +++ b/crates/jp_attachment_agentic_shepherd/Cargo.toml @@ -14,13 +14,12 @@ version.workspace = true [dependencies] jp_attachment = { workspace = true } -jp_mcp = { workspace = true } async-trait = { workspace = true } camino = { workspace = true } duct = { workspace = true } serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } +serde_json = { workspace = true, features = ["std"] } tracing = { workspace = true } url = { workspace = true } diff --git a/crates/jp_attachment_agentic_shepherd/src/lib.rs b/crates/jp_attachment_agentic_shepherd/src/lib.rs index 3e687e9f3..1f6904ae2 100644 --- a/crates/jp_attachment_agentic_shepherd/src/lib.rs +++ b/crates/jp_attachment_agentic_shepherd/src/lib.rs @@ -21,7 +21,6 @@ use camino::Utf8Path; use jp_attachment::{ Attachment, BoxedHandler, HANDLERS, Handler, distributed_slice, linkme, typetag, }; -use jp_mcp::Client; use serde::{Deserialize, Serialize}; use tracing::debug; use url::Url; @@ -79,11 +78,7 @@ impl Handler for AgenticShepherd { self.references.iter().map(Reference::to_url).collect() } - async fn get( - &self, - root: &Utf8Path, - _: Client, - ) -> Result, Box> { + async fn get(&self, root: &Utf8Path) -> Result, Box> { debug!( count = self.references.len(), "Fetching agentic-shepherd attachments." diff --git a/crates/jp_attachment_bear_note/Cargo.toml b/crates/jp_attachment_bear_note/Cargo.toml index b2aa75a8e..36e304b62 100644 --- a/crates/jp_attachment_bear_note/Cargo.toml +++ b/crates/jp_attachment_bear_note/Cargo.toml @@ -15,7 +15,6 @@ version.workspace = true [dependencies] grizzly = { workspace = true } jp_attachment = { workspace = true } -jp_mcp = { workspace = true } async-trait = { workspace = true } camino = { workspace = true } diff --git a/crates/jp_attachment_bear_note/src/lib.rs b/crates/jp_attachment_bear_note/src/lib.rs index 805884f64..a7bab4bf2 100644 --- a/crates/jp_attachment_bear_note/src/lib.rs +++ b/crates/jp_attachment_bear_note/src/lib.rs @@ -7,7 +7,6 @@ use jp_attachment::{ Attachment, BoxedHandler, HANDLERS, Handler, distributed_slice, linkme, percent_decode_str, percent_encode_str, typetag, }; -use jp_mcp::Client; use serde::{Deserialize, Serialize}; use tracing::debug; use url::Url; @@ -152,11 +151,7 @@ impl Handler for BearNotes { Ok(uris) } - async fn get( - &self, - _: &Utf8Path, - _: Client, - ) -> Result, Box> { + async fn get(&self, _: &Utf8Path) -> Result, Box> { let db = BearDb::open().map_err(|e| e.to_string())?; let mut attachments = vec![]; diff --git a/crates/jp_attachment_cmd_output/Cargo.toml b/crates/jp_attachment_cmd_output/Cargo.toml index 733c9582d..6068155c4 100644 --- a/crates/jp_attachment_cmd_output/Cargo.toml +++ b/crates/jp_attachment_cmd_output/Cargo.toml @@ -14,7 +14,6 @@ version.workspace = true [dependencies] jp_attachment = { workspace = true } -jp_mcp = { workspace = true } async-trait = { workspace = true } camino = { workspace = true } @@ -26,7 +25,6 @@ url = { workspace = true } [dev-dependencies] camino-tempfile = { workspace = true } -indexmap = { workspace = true } indoc = { workspace = true } test-log = { workspace = true } tokio = { workspace = true } diff --git a/crates/jp_attachment_cmd_output/src/lib.rs b/crates/jp_attachment_cmd_output/src/lib.rs index 88e7ae7ec..5d1cf05bb 100644 --- a/crates/jp_attachment_cmd_output/src/lib.rs +++ b/crates/jp_attachment_cmd_output/src/lib.rs @@ -6,7 +6,6 @@ use jp_attachment::{ Attachment, BoxedHandler, HANDLERS, Handler, distributed_slice, linkme, percent_decode_str, percent_encode_str, typetag, }; -use jp_mcp::Client; use serde::{Deserialize, Serialize}; use url::Url; @@ -106,11 +105,7 @@ impl Handler for Commands { Ok(commands) } - async fn get( - &self, - root: &Utf8Path, - _: Client, - ) -> Result, Box> { + async fn get(&self, root: &Utf8Path) -> Result, Box> { let mut attachments = vec![]; for command in &self.0 { let cmd_line = std::iter::once(command.cmd.clone()) diff --git a/crates/jp_attachment_cmd_output/src/lib_tests.rs b/crates/jp_attachment_cmd_output/src/lib_tests.rs index 3be90f9e6..838e572d4 100644 --- a/crates/jp_attachment_cmd_output/src/lib_tests.rs +++ b/crates/jp_attachment_cmd_output/src/lib_tests.rs @@ -1,4 +1,3 @@ -use indexmap::IndexMap; use test_log::test; use super::*; @@ -164,9 +163,8 @@ async fn test_commands_get_missing_binary_names_command() { ); let root = camino_tempfile::tempdir().unwrap(); - let client = Client::new(IndexMap::default()); let err = commands - .get(root.path(), client) + .get(root.path()) .await .expect_err("spawning a missing binary should error"); @@ -207,8 +205,7 @@ async fn test_commands_get() { std::fs::write(path.join("file1"), "").unwrap(); std::fs::write(path.join("file2"), "").unwrap(); - let client = Client::new(IndexMap::default()); - let attachments = commands.get(path, client).await.unwrap(); + let attachments = commands.get(path).await.unwrap(); assert_eq!(attachments, vec![ Attachment::text("false", indoc::indoc! {" diff --git a/crates/jp_attachment_file_content/Cargo.toml b/crates/jp_attachment_file_content/Cargo.toml index 2daae419a..fbe7f7dbe 100644 --- a/crates/jp_attachment_file_content/Cargo.toml +++ b/crates/jp_attachment_file_content/Cargo.toml @@ -14,7 +14,6 @@ version.workspace = true [dependencies] jp_attachment = { workspace = true } -jp_mcp = { workspace = true } async-trait = { workspace = true } camino = { workspace = true } @@ -29,7 +28,6 @@ url = { workspace = true } [dev-dependencies] camino-tempfile = { workspace = true } -indexmap = { workspace = true } test-log = { workspace = true } [lints] diff --git a/crates/jp_attachment_file_content/src/lib.rs b/crates/jp_attachment_file_content/src/lib.rs index cdc7f4bf8..fc18d4164 100644 --- a/crates/jp_attachment_file_content/src/lib.rs +++ b/crates/jp_attachment_file_content/src/lib.rs @@ -7,7 +7,6 @@ use ignore::{WalkBuilder, WalkState, overrides::OverrideBuilder}; use jp_attachment::{ Attachment, BoxedHandler, HANDLERS, Handler, distributed_slice, linkme, typetag, }; -use jp_mcp::Client; use serde::{Deserialize, Serialize}; use tracing::{debug, trace, warn}; use url::Url; @@ -93,11 +92,7 @@ impl Handler for FileContent { Ok(uris) } - async fn get( - &self, - cwd: &Utf8Path, - _: Client, - ) -> Result, Box> { + async fn get(&self, cwd: &Utf8Path) -> Result, Box> { debug!(id = self.scheme(), "Getting file attachment contents."); if self.includes.is_empty() { diff --git a/crates/jp_attachment_file_content/src/lib_tests.rs b/crates/jp_attachment_file_content/src/lib_tests.rs index e955cebab..9886ec934 100644 --- a/crates/jp_attachment_file_content/src/lib_tests.rs +++ b/crates/jp_attachment_file_content/src/lib_tests.rs @@ -1,6 +1,5 @@ use camino_tempfile::tempdir; use glob::Pattern; -use indexmap::IndexMap; use url::Url; use super::*; @@ -134,8 +133,7 @@ async fn test_file_get() -> Result<(), Box> { .add(&Url::parse("file:/file.txt")?, tmp.path()) .await?; - let client = Client::new(IndexMap::default()); - let attachments = handler.get(tmp.path(), client).await?; + let attachments = handler.get(tmp.path()).await?; assert_eq!(attachments.len(), 1); assert_eq!(attachments[0].source, "file.txt"); assert_eq!(attachments[0].as_text(), Some("content")); @@ -156,8 +154,7 @@ async fn test_file_get_image_png() -> Result<(), Box> { .add(&Url::parse("file:/screenshot.png")?, tmp.path()) .await?; - let client = Client::new(IndexMap::default()); - let attachments = handler.get(tmp.path(), client).await?; + let attachments = handler.get(tmp.path()).await?; assert_eq!(attachments.len(), 1); assert_eq!(attachments[0].source, "screenshot.png"); assert!(attachments[0].is_binary()); @@ -186,8 +183,7 @@ async fn test_file_get_image_jpeg() -> Result<(), Box> .add(&Url::parse("file:/photo.jpg")?, tmp.path()) .await?; - let client = Client::new(IndexMap::default()); - let attachments = handler.get(tmp.path(), client).await?; + let attachments = handler.get(tmp.path()).await?; assert_eq!(attachments.len(), 1); match &attachments[0].content { @@ -214,8 +210,7 @@ async fn test_file_get_pdf() -> Result<(), Box> { .add(&Url::parse("file:/doc.pdf")?, tmp.path()) .await?; - let client = Client::new(IndexMap::default()); - let attachments = handler.get(tmp.path(), client).await?; + let attachments = handler.get(tmp.path()).await?; assert_eq!(attachments.len(), 1); assert!(attachments[0].is_binary()); @@ -249,8 +244,7 @@ async fn test_file_get_mixed_text_and_binary() -> Result<(), Box Result, Box> { + async fn get(&self, _: &Utf8Path) -> Result, Box> { debug!(id = "gh", "Fetching GitHub attachments."); let mut attachments = Vec::with_capacity(self.urls.len()); diff --git a/crates/jp_attachment_http_content/Cargo.toml b/crates/jp_attachment_http_content/Cargo.toml index 5e1628e88..5d6aee69f 100644 --- a/crates/jp_attachment_http_content/Cargo.toml +++ b/crates/jp_attachment_http_content/Cargo.toml @@ -14,7 +14,6 @@ version.workspace = true [dependencies] jp_attachment = { workspace = true } -jp_mcp = { workspace = true } async-trait = { workspace = true } camino = { workspace = true } diff --git a/crates/jp_attachment_http_content/src/lib.rs b/crates/jp_attachment_http_content/src/lib.rs index 8c113e710..f8e67e7c7 100644 --- a/crates/jp_attachment_http_content/src/lib.rs +++ b/crates/jp_attachment_http_content/src/lib.rs @@ -6,7 +6,6 @@ use htmd::HtmlToMarkdown; use jp_attachment::{ Attachment, BoxedHandler, HANDLERS, Handler, distributed_slice, linkme, typetag, }; -use jp_mcp::Client; use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue, USER_AGENT}; use serde::{Deserialize, Serialize}; use tracing::{debug, error}; @@ -67,11 +66,7 @@ impl Handler for HttpContent { Ok(self.urls.iter().cloned().collect()) } - async fn get( - &self, - _: &Utf8Path, - _: Client, - ) -> Result, Box> { + async fn get(&self, _: &Utf8Path) -> Result, Box> { debug!(id = "http", "Getting http attachment contents."); fetch_all(&self.urls).await } @@ -104,11 +99,7 @@ impl Handler for HttpContent { Ok(self.urls.iter().cloned().collect()) } - async fn get( - &self, - _: &Utf8Path, - _: Client, - ) -> Result, Box> { + async fn get(&self, _: &Utf8Path) -> Result, Box> { debug!(id = "https", "Getting https attachment contents."); fetch_all(&self.urls).await } diff --git a/crates/jp_attachment_mcp_resources/Cargo.toml b/crates/jp_attachment_mcp_resources/Cargo.toml index 95803e0c3..95dbdbcb8 100644 --- a/crates/jp_attachment_mcp_resources/Cargo.toml +++ b/crates/jp_attachment_mcp_resources/Cargo.toml @@ -14,17 +14,17 @@ version.workspace = true [dependencies] jp_attachment = { workspace = true } -jp_mcp = { workspace = true } async-trait = { workspace = true } camino = { workspace = true } -quick-xml = { workspace = true, features = ["serialize"] } serde = { workspace = true } url = { workspace = true, features = ["serde"] } +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt"] } + [lints] workspace = true [lib] -test = false doctest = false diff --git a/crates/jp_attachment_mcp_resources/src/lib.rs b/crates/jp_attachment_mcp_resources/src/lib.rs index 7d431eb8a..2a98f7ab4 100644 --- a/crates/jp_attachment_mcp_resources/src/lib.rs +++ b/crates/jp_attachment_mcp_resources/src/lib.rs @@ -1,11 +1,19 @@ -use std::{collections::BTreeSet, error::Error}; +//! The `mcp` attachment scheme, kept readable but no longer resolvable. +//! +//! Conversations recorded before MCP resource attachments were retired still +//! carry `mcp++://` entries under the `mcp` handler tag. +//! This handler keeps deserializing, listing, and removing them so those +//! conversations load, are inspectable, and can be edited. +//! Resolving one reports [`UnsupportedResolution`] instead of reading from an +//! MCP server. + +use std::{collections::BTreeSet, error::Error, fmt}; use async_trait::async_trait; use camino::Utf8Path; use jp_attachment::{ Attachment, BoxedHandler, HANDLERS, Handler, distributed_slice, linkme, typetag, }; -use jp_mcp::{Client, ResourceContents, id::McpServerId}; use serde::{Deserialize, Serialize}; use url::Url; @@ -20,34 +28,38 @@ fn handler() -> BoxedHandler { #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, Hash)] pub struct McpResources(BTreeSet); -/// Output from a command. -#[derive(Debug, Clone, PartialEq, Serialize)] -struct Resource(Vec); - -impl Resource { - pub fn try_to_xml(&self) -> Result> { - let mut buffer = String::new(); - let mut serializer = quick_xml::se::Serializer::new(&mut buffer); - serializer.indent(' ', 2); - self.serialize(serializer)?; - Ok(buffer) - } +/// Returned when an `mcp` attachment is asked for its contents. +/// +/// Names every attachment the conversation carries, so one query tells the user +/// the whole set to remove rather than one per attempt. +#[derive(Debug)] +pub struct UnsupportedResolution { + uris: Vec, } -impl From> for Resource { - fn from(contents: Vec) -> Self { - Resource( - contents - .into_iter() - .filter_map(|c| match c { - ResourceContents::TextResourceContents { text, .. } => Some(text), - ResourceContents::BlobResourceContents { .. } => None, - }) - .collect(), +impl fmt::Display for UnsupportedResolution { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let list = self + .uris + .iter() + .map(|uri| format!("`{uri}`")) + .collect::>() + .join(", "); + let removals = self + .uris + .iter() + .map(|uri| format!("jp attachment rm '{uri}'")) + .collect::>() + .join(" && "); + write!( + f, + "MCP resource attachments are no longer resolved: {list}. Remove them with: {removals}" ) } } +impl Error for UnsupportedResolution {} + #[typetag::serde(name = "mcp")] #[async_trait] impl Handler for McpResources { @@ -75,32 +87,16 @@ impl Handler for McpResources { Ok(self.0.clone().into_iter().collect()) } - async fn get( - &self, - _: &Utf8Path, - client: Client, - ) -> Result, Box> { - let mut attachments = vec![]; - for uri in &self.0 { - // "mcp+github-mcp-server+repo" -> ("mcp+github-mcp-server", "repo") - let (mcp, scheme) = uri.scheme().rsplit_once('+').unwrap_or(("", uri.scheme())); - - // "mcp+github-mcp-server" -> "github-mcp-server" - let server_id = McpServerId::new(mcp.split_once('+').unwrap_or(("", mcp)).1); - - let mut resource_uri = uri.clone(); - let _ = resource_uri.set_scheme(scheme); - - let resource = client - .get_resource_contents(&server_id, resource_uri) - .await?; - - attachments.push(Attachment::text( - uri.to_string(), - Resource::from(resource).try_to_xml()?, - )); + async fn get(&self, _: &Utf8Path) -> Result, Box> { + if self.0.is_empty() { + return Ok(vec![]); } - - Ok(attachments) + Err(Box::new(UnsupportedResolution { + uris: self.0.iter().cloned().collect(), + })) } } + +#[cfg(test)] +#[path = "lib_tests.rs"] +mod tests; diff --git a/crates/jp_attachment_mcp_resources/src/lib_tests.rs b/crates/jp_attachment_mcp_resources/src/lib_tests.rs new file mode 100644 index 000000000..85e9c30a4 --- /dev/null +++ b/crates/jp_attachment_mcp_resources/src/lib_tests.rs @@ -0,0 +1,69 @@ +use jp_attachment::Handler as _; + +use super::*; + +fn uri() -> Url { + Url::parse("mcp+github-mcp-server+repo://owner/name").unwrap() +} + +#[tokio::test] +async fn stored_attachment_is_listed_and_removable() { + let mut handler = McpResources::default(); + handler.add(&uri(), Utf8Path::new("/")).await.unwrap(); + + assert_eq!(handler.list().await.unwrap(), vec![uri()]); + + handler.remove(&uri()).await.unwrap(); + assert_eq!(handler.list().await.unwrap(), Vec::::new()); +} + +#[tokio::test] +async fn resolving_a_stored_attachment_names_it_and_how_to_remove_it() { + let mut handler = McpResources::default(); + handler.add(&uri(), Utf8Path::new("/")).await.unwrap(); + + let error = handler + .get(Utf8Path::new("/")) + .await + .expect_err("mcp resource attachments no longer resolve"); + + assert_eq!( + error.to_string(), + "MCP resource attachments are no longer resolved: \ + `mcp+github-mcp-server+repo://owner/name`. Remove them with: jp attachment rm \ + 'mcp+github-mcp-server+repo://owner/name'" + ); +} + +/// A conversation carrying several of them names all of them at once, so the +/// user does not learn about the next one by querying again. +#[tokio::test] +async fn resolving_names_every_stored_attachment() { + let second = Url::parse("mcp+other-server+file:///notes.md").unwrap(); + let mut handler = McpResources::default(); + handler.add(&uri(), Utf8Path::new("/")).await.unwrap(); + handler.add(&second, Utf8Path::new("/")).await.unwrap(); + + let error = handler + .get(Utf8Path::new("/")) + .await + .expect_err("mcp resource attachments no longer resolve"); + + assert_eq!( + error.to_string(), + "MCP resource attachments are no longer resolved: \ + `mcp+github-mcp-server+repo://owner/name`, `mcp+other-server+file:///notes.md`. Remove \ + them with: jp attachment rm 'mcp+github-mcp-server+repo://owner/name' && jp attachment \ + rm 'mcp+other-server+file:///notes.md'" + ); +} + +/// A handler registered but never given a URI has nothing to refuse: the +/// scheme's presence in the registry must not fail a query that carries no +/// `mcp` attachment. +#[tokio::test] +async fn an_empty_handler_resolves_to_nothing() { + let handler = McpResources::default(); + + assert_eq!(handler.get(Utf8Path::new("/")).await.unwrap(), vec![]); +} diff --git a/crates/jp_cli/Cargo.toml b/crates/jp_cli/Cargo.toml index 606b67ab2..29c168b9e 100644 --- a/crates/jp_cli/Cargo.toml +++ b/crates/jp_cli/Cargo.toml @@ -31,7 +31,7 @@ jp_id = { workspace = true } jp_inquire = { workspace = true } jp_llm = { workspace = true } jp_macro = { workspace = true } -jp_mcp = { workspace = true } +jp_mcp = { workspace = true, features = ["server"] } jp_md = { workspace = true } jp_openrouter = { workspace = true } jp_plugin = { workspace = true } @@ -72,10 +72,12 @@ indoc = { workspace = true } inquire = { workspace = true, features = ["crossterm"] } minijinja = { workspace = true } quick-xml = { workspace = true, features = ["serialize"] } +rand = { workspace = true, features = ["thread_rng"] } rayon = { workspace = true } regex = { workspace = true, features = ["perf", "std", "unicode"] } relative-path = { workspace = true } reqwest = { workspace = true } +rmcp = { workspace = true, features = ["client"] } schemars = { workspace = true } schematic = { workspace = true, features = ["schema_serde", "renderer_template", "toml"] } serde = { workspace = true } @@ -125,6 +127,7 @@ insta = { workspace = true } pretty_assertions = { workspace = true, features = ["std"] } serial_test = { workspace = true } test-log = { workspace = true } +tokio = { workspace = true, features = ["test-util"] } [lints] workspace = true diff --git a/crates/jp_cli/src/cmd.rs b/crates/jp_cli/src/cmd.rs index cbeb2a2e9..d6a827de5 100644 --- a/crates/jp_cli/src/cmd.rs +++ b/crates/jp_cli/src/cmd.rs @@ -8,7 +8,7 @@ pub(crate) mod label; mod lock; pub(crate) mod plugin; mod provider; -mod query; +pub(crate) mod query; pub(crate) mod target; pub(crate) mod time; pub(crate) mod turn_selection; @@ -448,6 +448,8 @@ impl From for Error { Workspace(error) => return error.into(), Conversation(error) => return error.into(), Mcp(error) => return error.into(), + McpEndpoint(error) => [("message", error.to_string())].into(), + McpHost(error) => with_cause(&error, "MCP Host control failed"), Llm(error) => return error.into(), Io(error) => return error.into(), Url(error) => return error.into(), @@ -719,7 +721,7 @@ impl_from_error!(jp_storage::LoadError, "Storage load error"); impl_from_error!(jp_config::ConfigError, "Config error"); impl_from_error!(jp_config::fs::ConfigLoaderError, "Config loader error"); impl_from_error!(jp_conversation::Error, "Conversation error"); -impl_from_error!(jp_llm::ToolError, "Tool error"); +impl_from_error!(jp_tool::Error, "Tool error"); impl_from_error!(jp_mcp::Error, "MCP error"); impl_from_error!(minijinja::Error, "Template error"); impl_from_error!(quick_xml::SeError, "XML serialization error"); @@ -790,6 +792,7 @@ impl From for Error { ("response", response), ] .into(), + AnthropicAcp(error) => with_cause(&error, "Anthropic ACP subscription error"), Anthropic(anthropic_error) => [ ("message", "Anthropic error".into()), ("error", anthropic_error.to_string()), diff --git a/crates/jp_cli/src/cmd/attachment.rs b/crates/jp_cli/src/cmd/attachment.rs index bdf853be1..a8fb67f38 100644 --- a/crates/jp_cli/src/cmd/attachment.rs +++ b/crates/jp_cli/src/cmd/attachment.rs @@ -1,4 +1,3 @@ -use camino::Utf8Path; use jp_attachment_agentic_shepherd as _; use jp_attachment_bear_note as _; use jp_attachment_cmd_output as _; @@ -103,52 +102,6 @@ pub(crate) fn validate_attachment(uri: &Url) -> Result<()> { Ok(()) } -/// Whether resolving this attachment reads from a running MCP server. -/// -/// Such an attachment can only be resolved once the server is up: -/// [`jp_mcp::Client::get_resource_contents`] reads the running-services map and -/// does not start a server on demand. -pub(crate) fn needs_mcp_server(uri: &Url) -> bool { - attachment_scheme(uri) == "mcp" -} - -/// Resolve attachments through their handlers, without a [`Ctx`]. -/// -/// Takes the two things a handler is given so a caller that no longer holds the -/// context can still resolve one. -/// `jp://` is not handled here: reading a conversation needs the workspace. -/// -/// Returns one group per URL, in the order the URLs were given. -/// A URL can yield several attachments, so a caller that has to place them back -/// among others needs the grouping to know where each one ends. -pub(crate) async fn resolve_attachments( - root: &Utf8Path, - mcp_client: &jp_mcp::Client, - urls: Vec, -) -> Result>> { - let futs = urls.into_iter().map(|uri| async move { - let scheme = attachment_scheme(&uri); - let Some(mut handler) = jp_attachment::find_handler_by_scheme(scheme) else { - return Err(Error::NotFound("Attachment handler", scheme.to_string())); - }; - - handler - .add(&uri, root) - .await - .map_err(|source| Error::AttachmentFailed { - uri: uri.clone(), - source, - })?; - - handler - .get(root, mcp_client.clone()) - .await - .map_err(|source| Error::AttachmentFailed { uri, source }) - }); - - futures::future::try_join_all(futs).await -} - pub(crate) async fn register_attachment( ctx: &Ctx, uri: Url, @@ -180,7 +133,7 @@ pub(crate) async fn register_attachment( })?; handler - .get(ctx.workspace.root(), ctx.mcp_client.clone()) + .get(ctx.workspace.root()) .await .map_err(|source| Error::AttachmentFailed { uri, source }) } diff --git a/crates/jp_cli/src/cmd/attachment_tests.rs b/crates/jp_cli/src/cmd/attachment_tests.rs index 882e5d2fd..1258d0790 100644 --- a/crates/jp_cli/src/cmd/attachment_tests.rs +++ b/crates/jp_cli/src/cmd/attachment_tests.rs @@ -9,27 +9,6 @@ use url::Url; use super::*; use crate::{Globals, ctx::Ctx, error::Error}; -/// An `mcp+…` attachment is the one kind that cannot resolve until its server -/// is running, so the query path holds it back until after the startup wait. -#[test] -fn only_mcp_attachments_wait_for_a_server() { - let mcp = Url::parse("mcp+github-mcp-server+repo://owner/repo/contents/README.md").unwrap(); - assert!(needs_mcp_server(&mcp)); - - for other in [ - "jp://17861332336", - "file://./README.md", - "https://example.com/page", - "cmd://git?arg=diff", - ] { - let url = Url::parse(other).unwrap(); - assert!( - !needs_mcp_server(&url), - "{other} resolves without a running MCP server" - ); - } -} - fn make_id(secs: u64) -> ConversationId { ConversationId::try_from( chrono::DateTime::::UNIX_EPOCH + std::time::Duration::from_secs(secs), diff --git a/crates/jp_cli/src/cmd/conversation/print.rs b/crates/jp_cli/src/cmd/conversation/print.rs index 1c4ede7e3..86c23e1eb 100644 --- a/crates/jp_cli/src/cmd/conversation/print.rs +++ b/crates/jp_cli/src/cmd/conversation/print.rs @@ -5,7 +5,6 @@ use jp_config::{ style::{reasoning::ReasoningDisplayConfig, typewriter::DelayDuration}, }; use jp_conversation::stream::TurnOrigin; -use jp_llm::tool::InvocationContext; use jp_workspace::ConversationHandle; use crate::{ @@ -153,11 +152,6 @@ impl Print { let raw_count = events.turn_count(); let cfg = ctx.config(); - let root = ctx - .storage_path() - .unwrap_or(ctx.workspace.root()) - .to_path_buf(); - let source = if current_config { ConfigSource::Fixed } else { @@ -176,20 +170,13 @@ impl Print { let assistant_name = cfg.assistant.name.clone(); let model_id = Some(cfg.assistant.model.id.resolved().to_string()); - let invocation = InvocationContext { - workspace_id: ctx.workspace.id().to_string(), - conversation_id: handle.id().to_string(), - }; - let mut renderer = TurnRenderer::new( ctx.printer.clone(), render_style, tools_config, assistant_name, model_id, - root, source, - invocation, style_overlay, ); renderer.set_user_only(user_only); diff --git a/crates/jp_cli/src/cmd/conversation/summarize.rs b/crates/jp_cli/src/cmd/conversation/summarize.rs index 740cd9965..c382e18ae 100644 --- a/crates/jp_cli/src/cmd/conversation/summarize.rs +++ b/crates/jp_cli/src/cmd/conversation/summarize.rs @@ -260,7 +260,10 @@ fn summarize_events(events: Vec) -> StreamOutcome { Event::Patch(mut p) => patches.append(&mut p), // `KeepAlive` is a liveness signal, and `collect_with_retry` has // already delivered every notice to its sink. - Event::KeepAlive | Event::Notice(_) => {} + Event::KeepAlive + | Event::Notice(_) + | Event::ToolCallPending { .. } + | Event::ToolCallPendingEnd { .. } => {} } } diff --git a/crates/jp_cli/src/cmd/query.rs b/crates/jp_cli/src/cmd/query.rs index 98c2e241d..b75297cec 100644 --- a/crates/jp_cli/src/cmd/query.rs +++ b/crates/jp_cli/src/cmd/query.rs @@ -46,7 +46,9 @@ //! [`ToolCallResponse`]: jp_conversation::event::ToolCallResponse //! [`TurnCoordinator`]: turn::coordinator::TurnCoordinator +mod args; pub(crate) mod interrupt; +mod mcp_startup; mod stream; pub(crate) mod tool; mod turn; @@ -54,19 +56,17 @@ mod turn_loop; use std::{ borrow::Cow, - collections::{HashMap, HashSet}, - env, - fmt::Write as _, - fs, + collections::HashSet, + env, fs, io::{self, IsTerminal}, sync::Arc, time::{Duration, Instant}, }; +pub(crate) use args::{QueryInput, ToolDirective, ToolDirectives}; use camino::{Utf8Path, Utf8PathBuf}; use chrono::{DateTime, Utc}; use clap::{ArgAction, builder::TypedValueParser as _}; -use crossterm::style::Stylize as _; use indexmap::IndexMap; use jp_attachment::Attachment; use jp_config::{ @@ -86,10 +86,14 @@ use jp_config::{ }, }, fs::{expand_tilde, load_partial}, - model::parameters::{ - PartialCustomReasoningConfig, PartialReasoningConfig, ReasoningConfig, ServiceTier, + model::{ + id::{PartialModelIdOrAliasConfig, ProviderId}, + parameters::{ + PartialCustomReasoningConfig, PartialReasoningConfig, ReasoningConfig, ServiceTier, + }, }, - style::{mcp_startup::McpStartupConfig, reasoning::ReasoningDisplayConfig}, + providers::llm::AuthEntry, + style::reasoning::ReasoningDisplayConfig, }; use jp_conversation::{ Conversation, ConversationEvent, ConversationId, ConversationStream, Labels, @@ -98,28 +102,24 @@ use jp_conversation::{ thread::{Thread, ThreadBuilder}, }; use jp_inquire::prompt::{PromptBackend, TerminalPromptBackend}; -use jp_llm::{ - ToolError, - event::NoticeSink, - provider, - tool::{ - InvocationContext, ToolDefinition, ToolDocs, +use jp_llm::{event::NoticeSink, provider}; +use jp_mcp::{ + StartupSet, + server::{ builtin::{BuiltinExecutors, describe_tools::DescribeTools}, tool_definitions, }, }; -use jp_mcp::{StartupSet, id::McpServerId}; use jp_md::format::Formatter; -use jp_printer::{LineSink, PrintableExt as _, Printer, RegionStyle, StatusRegion}; +use jp_printer::Printer; use jp_storage::backend::{FsStorageBackend, Projection}; use jp_task::task::TitleGeneratorTask; -use jp_term::width::{display_width, truncate_to_width}; +use jp_tool::{Error as ToolError, InvocationContext, ToolDefinition, ToolDocs}; use jp_workspace::{ ConversationHandle, ConversationLock, ConversationMut, Id as WorkspaceId, Workspace, }; use minijinja::{Environment, UndefinedBehavior}; use strip_ansi_escapes::strip_str; -use tokio::sync::broadcast::error::RecvError; use tool::{TerminalExecutorSource, ToolCoordinator}; use tracing::{debug, info, trace, warn}; use turn_loop::run_turn_loop; @@ -127,7 +127,7 @@ use url::Url; use super::{ ConversationLoadRequest, Output, - attachment::{load_conversation_attachments, needs_mcp_server, resolve_attachments}, + attachment::load_conversation_attachments, conversation_id::{ConversationIds, FlagIds}, lock::LockOutcome, target::TargetGrammar, @@ -150,8 +150,8 @@ use crate::{ editor, error::{Error, Result}, output::{notice_sink, print_json}, - parser::{AttachmentUrlOrPath, split_list}, - render::{RenderFlow, TurnView, tool::output_lines}, + parser::AttachmentUrlOrPath, + render::{RenderFlow, TurnView}, signals::{SignalRouter, TurnInterrupt}, }; @@ -251,6 +251,19 @@ pub(crate) struct Query { #[arg(short = 'm', long = "model")] model: Option, + /// Which credential to bill this turn to. + /// + /// Takes the same entries as the `auth` chain in configuration, comma + /// separated: a credential name, `api_key`, `subscription`, or + /// `:`. + /// `api` and `sub` are accepted for the kinds. + /// + /// Applies to the provider the chosen model belongs to, and is recorded on + /// the turn, so the rest of the conversation keeps billing the same way + /// until another `--auth` changes it. + #[arg(long = "auth", value_name = "CHAIN", value_delimiter = ',')] + auth: Vec, + /// The model parameters to use. #[arg(short = 'p', long = "param", value_name = "KEY=VALUE", action = ArgAction::Append)] parameters: Vec, @@ -1153,20 +1166,29 @@ impl Query { .collect(); let builtin_executors = BuiltinExecutors::new().register("describe_tools", DescribeTools::new(docs_map)); - let executor_source = - TerminalExecutorSource::new(builtin_executors, tools, approvals, invocation.clone()); + let (executor_source, execution_owner) = TerminalExecutorSource::start_with_metadata( + builtin_executors, + tools, + &cfg.conversation.tools, + approvals, + invocation.clone(), + mcp_client, + root.clone(), + provider.mcp_tool_metadata(&model), + ) + .await?; let tool_coordinator = ToolCoordinator::new(cfg.conversation.tools.clone(), Box::new(executor_source)) .with_interrupt(cfg.interrupt.tool_call.clone()); let prompt_backend = Arc::new(TerminalPromptBackend); - run_turn_loop( + let result = run_turn_loop( provider, &model, cfg, signals, - mcp_client, &root, + invocation, interactive, attachments, lock, @@ -1176,11 +1198,17 @@ impl Query { prompt_backend, tool_coordinator, chat_request, - invocation, pending_trim, turn_interrupt, ) - .await + .await; + if let Err(error) = execution_owner.shutdown().await { + if result.is_ok() { + return Err(error.into()); + } + warn!(%error, "MCP execution service cleanup failed"); + } + result } /// Whether the chat request should be echoed to the terminal before the @@ -1390,69 +1418,6 @@ impl AcquiredConversation { } } -/// One configured attachment, at the position the user declared it. -enum AttachmentSlot { - /// Resolved while the context was still in hand. - Ready(Vec), - - /// Read from an MCP server, so it waits for one to be running. - Deferred(Url), -} - -/// The turn's attachments, some of which cannot resolve yet. -/// -/// Resolving one can read a conversation out of the workspace, fetch over HTTP, -/// or read a resource from an MCP server. -/// The first needs a context the turn no longer holds and the last needs a -/// server that is still starting, so they are resolved at different points and -/// meet here. -/// -/// One slot per configured attachment, in declaration order. -/// The order reaches the provider: every attachment is sent as a document in -/// this order, numbered by its position. -struct PendingAttachments { - slots: Vec, -} - -impl PendingAttachments { - /// Resolve what is left and return the whole set, in declaration order. - async fn resolve( - self, - root: &Utf8Path, - mcp_client: &jp_mcp::Client, - ) -> Result> { - let deferred: Vec = self - .slots - .iter() - .filter_map(|slot| match slot { - AttachmentSlot::Deferred(url) => Some(url.clone()), - AttachmentSlot::Ready(_) => None, - }) - .collect(); - - let resolved = resolve_attachments(root, mcp_client, deferred).await?; - - Ok(splice(self.slots, resolved)) - } -} - -/// Flatten the slots, putting each resolved group back where its URL was. -/// -/// `deferred` holds one group per [`AttachmentSlot::Deferred`], in slot order: -/// the caller collects those URLs in that order and the resolver answers in -/// kind. -fn splice(slots: Vec, deferred: Vec>) -> Vec { - let mut deferred = deferred.into_iter(); - - slots - .into_iter() - .flat_map(|slot| match slot { - AttachmentSlot::Ready(attachments) => attachments, - AttachmentSlot::Deferred(_) => deferred.next().unwrap_or_default(), - }) - .collect() -} - /// Everything a turn needs, gathered in one place. /// /// Collecting reads the context; running does not. @@ -1475,8 +1440,12 @@ pub(crate) struct TurnInputs { /// Whether a user is there to answer a prompt or approve a tool call. interactive: bool, - /// What the assistant is given alongside the conversation. - attachments: PendingAttachments, + /// What the assistant is given alongside the conversation, in declaration + /// order. + /// + /// The order reaches the provider: every attachment is sent as a document + /// in this order, numbered by its position. + attachments: Vec, /// Where the turn's output goes. printer: Arc, @@ -1507,9 +1476,6 @@ impl TurnInputs { /// attachment here can fetch over HTTP, call the GitHub API, or shell out, /// and this waits for all of them. /// - /// An attachment that reads from an MCP server is the exception, held back - /// for [`Self::run`] to resolve once the servers it needs are up. - /// /// `printer` is where the turn's output goes: the terminal's printer for a /// turn typed there, or a sink printer, which writes nothing, for a turn /// started from somewhere with no terminal attached. @@ -1535,33 +1501,15 @@ impl TurnInputs { .map(AttachmentConfig::to_url) .collect::, _>>()?; - // Resolve what can be resolved now, then rebuild the declared order - // with a placeholder where each MCP-backed attachment goes. - let eager: Vec = urls - .iter() - .filter(|url| !needs_mcp_server(url)) - .cloned() - .collect(); - - let mut ready = load_conversation_attachments(ctx, eager).await?.into_iter(); - let slots: Vec = urls - .iter() - .map(|url| { - if needs_mcp_server(url) { - AttachmentSlot::Deferred(url.clone()) - } else { - AttachmentSlot::Ready(ready.next().unwrap_or_default()) - } - }) + // One group per URL, in declaration order, flattened into the order + // the provider receives them in. + let attachments: Vec = load_conversation_attachments(ctx, urls) + .await? + .into_iter() + .flatten() .collect(); - let deferred: Vec<&Url> = urls.iter().filter(|url| needs_mcp_server(url)).collect(); - debug!( - count = urls.len(), - deferred = deferred.len(), - deferred_uris = ?deferred.iter().map(|url| url.as_str()).collect::>(), - "Attachments loaded." - ); + debug!(count = attachments.len(), "Attachments loaded."); Ok(Self { workspace_root: ctx.workspace.root().to_path_buf(), @@ -1571,7 +1519,7 @@ impl TurnInputs { mcp_client: ctx.mcp_client.clone(), printer, interactive, - attachments: PendingAttachments { slots }, + attachments, mcp_servers, chat_request, pending_trim, @@ -1592,37 +1540,23 @@ impl TurnInputs { ) -> Result<()> { let cfg = &self.config; - let prepared = tokio::select! { + let tools = tokio::select! { result = async { // Wait for all MCP servers to finish loading, showing a timer line // when the wait takes long enough to be noticeable. let waited = Instant::now(); - let skipped = await_mcp_servers( + let skipped = mcp_startup::await_mcp_servers( self.mcp_servers, cfg.style.mcp_startup.clone(), self.printer.clone(), ) .await?; - report_skipped_servers(&self.printer, cfg, &skipped); + mcp_startup::report_skipped_servers(&self.printer, cfg, &skipped); debug!( elapsed_ms = waited.elapsed().as_millis(), "MCP servers ready." ); - // Only now can the deferred ones resolve: the handler reads a - // resource from a running server, and until the wait above returns - // there is none. - let resolving = Instant::now(); - let attachments = self - .attachments - .resolve(&self.workspace_root, &self.mcp_client) - .await?; - debug!( - count = attachments.len(), - elapsed_ms = resolving.elapsed().as_millis(), - "Attachments resolved." - ); - let forced_tool = cfg.assistant.tool_choice.function_name(); let tools = tool_definitions( cfg.conversation.tools.iter(), @@ -1632,7 +1566,7 @@ impl TurnInputs { .await?; debug!(count = tools.len(), forced_tool, "Tools resolved."); - Ok::<_, Error>((attachments, tools)) + Ok::<_, Error>(tools) } => result?, notified = turn_interrupt.recv() => { @@ -1648,8 +1582,7 @@ impl TurnInputs { } }; - let (attachments, tools) = prepared; - let thread = build_thread(stream, attachments, &cfg.assistant, !tools.is_empty())?; + let thread = build_thread(stream, self.attachments, &cfg.assistant, !tools.is_empty())?; debug!( events = thread.events.len(), attachments = thread.attachments.len(), @@ -1686,214 +1619,6 @@ impl TurnInputs { } } -/// Wait for background MCP server startups to complete. -/// -/// Shows an aggregate status row on stderr once the wait exceeds the configured -/// delay, updating the listed server names as startups finish, with a rolling -/// window of the servers' own stderr above it. -/// Servers that finish within the delay never trigger the row. -/// -/// Returns the optional servers that failed and were skipped, so the caller can -/// account for the tools that went with them. -/// A required server's failure is returned as an error instead; the rows are -/// erased on the way out, so it renders on a clean line. -async fn await_mcp_servers( - mut startup: StartupSet, - config: McpStartupConfig, - printer: Arc, -) -> std::result::Result, cmd::Error> { - if startup.joins.is_empty() { - return Ok(Vec::new()); - } - - let region = claim_mcp_startup_region(&printer, &config); - region.set_detail(mcp_startup_status(&startup.pending)); - - // One sink per pending server, dropped the moment that server's join - // completes. The forwarder behind the channel runs until the *server* - // exits, which is long after it finished starting; a sink left open would - // let a started server's operational logging evict the build output of one - // still compiling. - let mut sinks: HashMap = startup - .pending - .iter() - .map(|id| (id.clone(), region.source(id.as_str()))) - .collect(); - - let mut skipped = Vec::new(); - let mut lines_open = true; - - let result = loop { - tokio::select! { - line = startup.stderr.recv(), if lines_open => match line { - Ok((id, text)) => if let Some(sink) = sinks.get(&id) { - sink.push(text); - }, - // The window shows the most recent lines by definition, so - // falling behind costs nothing worth reporting. - Err(RecvError::Lagged(_)) => {} - Err(RecvError::Closed) => lines_open = false, - }, - joined = startup.joins.join_next() => match joined { - None => break Ok(()), - Some(Err(error)) => break Err(cmd::Error::from(error)), - Some(Ok(Err(error))) => break Err(cmd::Error::from(error)), - Some(Ok(Ok(outcome))) => { - let id = outcome.id(); - sinks.remove(id); - startup.pending.retain(|pending| pending != id); - if outcome.was_skipped() { - skipped.push(id.clone()); - } - if !startup.pending.is_empty() { - region.set_detail(mcp_startup_status(&startup.pending)); - } - } - }, - } - }; - - result.map(|()| skipped) -} - -/// Report optional MCP servers that failed to start. -/// -/// A skipped server completes the wait successfully, so without this the query -/// quietly loses tools: the `warn!` explaining why goes to the trace log, which -/// is discarded unless the run itself fails. -/// -/// Emitted whatever `style.mcp_startup.show` and `stderr_rows` say. -/// Those keys gate progress display; gating a failure report behind them would -/// reproduce the silence this closes. -/// -/// `--format json` gets the parts rather than a sentence about them: a program -/// deciding what to do about a missing server reads `server` and `tools`, and -/// can render its own prose from them if it wants any. -fn report_skipped_servers(printer: &Printer, config: &AppConfig, skipped: &[McpServerId]) { - for id in skipped { - let tools = tools_backed_by(config, id); - - if printer.format().is_json() { - printer.println_raw(skipped_server_record(printer, id, &tools).to_err()); - continue; - } - - let mut line = format!("Optional MCP server '{id}' did not start"); - if !tools.is_empty() { - let _err = write!(line, "; unavailable tools: {}", tools.join(", ")); - } - line.push_str(" (run with -v for the reason)"); - - printer.eprintln(line.yellow().to_string()); - } -} - -/// Serialize one skipped-server report, indented when the format asks for it. -fn skipped_server_record(printer: &Printer, id: &McpServerId, tools: &[String]) -> String { - let record = serde_json::json!({ - "event": "mcp_server_unavailable", - "server": id.as_str(), - "tools": tools, - }); - - if printer.format().is_json_pretty() { - serde_json::to_string_pretty(&record) - } else { - serde_json::to_string(&record) - } - .unwrap_or_else(|_| record.to_string()) -} - -/// Names of the enabled tools sourced from `server`. -/// -/// Sorted, so the report reads the same way twice. -fn tools_backed_by(config: &AppConfig, server: &McpServerId) -> Vec { - let mut names: Vec = config - .conversation - .tools - .iter() - .filter(|(_, tool)| tool.is_enabled()) - .filter(|(_, tool)| match tool.source() { - ToolSource::Mcp { server: name, .. } => &McpServerId::new(name.as_str()) == server, - _ => false, - }) - .map(|(name, _)| name.to_string()) - .collect(); - - names.sort(); - names -} - -/// Claim the status region for the MCP server startup wait. -/// -/// Returns an inert region when `style.mcp_startup.show` is off, or when the -/// terminal cannot carry one. -fn claim_mcp_startup_region(printer: &Printer, config: &McpStartupConfig) -> StatusRegion { - if !config.show { - return StatusRegion::inert(); - } - - // The row bounds itself rather than letting the region cut its tail: the - // elapsed time lives at the end, and a long server list would take it with - // it. - let columns = printer.chrome_columns(); - - printer.status_region( - RegionStyle::new( - Duration::from_secs(config.delay_secs.into()), - Duration::from_millis(config.interval_ms.into()), - move |secs, detail| mcp_startup_line(secs, detail, columns), - ) - .with_output(output_lines(config.stderr_rows)), - ) -} - -/// Render the MCP startup status row for `secs` elapsed and `status`, bounding -/// the visible text to `width` columns when known. -/// -/// Truncation falls on the server-list fragment only: the ` ⏱ Starting ` -/// prefix and the ` {secs:.1}s ` timer suffix are always preserved, so the -/// elapsed time keeps moving even when a long list overflows. -/// A terminal too narrow for even the prefix and suffix falls back to a bounded -/// `⏱ {secs:.1}s`. -fn mcp_startup_line(secs: f64, status: Option<&str>, width: Option) -> String { - let status = status.unwrap_or("MCP servers"); - let full = format!("⏱ Starting {status}… {secs:.1}s"); - match width { - Some(w) if display_width(&full) > usize::from(w) => { - let w = usize::from(w); - let prefix = "⏱ Starting "; - let suffix = format!(" {secs:.1}s"); - let reserved = display_width(prefix) + display_width(&suffix); - if w <= reserved { - truncate_to_width(&format!("⏱ {secs:.1}s"), w) - } else { - let status = truncate_to_width(status, w - reserved); - format!("{prefix}{status}{suffix}") - } - } - _ => full, - } -} - -/// Render the pending-server fragment for the MCP startup timer line. -/// -/// One server renders as `MCP server bookworm`; several render as `2 MCP -/// servers (bookworm, grizzly)`. -fn mcp_startup_status(pending: &[McpServerId]) -> String { - match pending { - [id] => format!("MCP server {id}"), - ids => format!( - "{} MCP servers ({})", - ids.len(), - ids.iter() - .map(McpServerId::as_str) - .collect::>() - .join(", ") - ), - } -} - /// Return the most recent assistant message text in the stream. /// /// Walks the stream in reverse and returns the first `ChatResponse::Message` it @@ -1979,336 +1704,6 @@ fn reformat_quoted_message(message: &str, config: &AppConfig) -> String { strip_str(rendered).trim_end().to_owned() } -/// The query text and the `--quote` seed. -/// -/// The two are parsed together because `--quote` accepts its value either -/// attached (`--quote=false`) or as the word right after the flag (`--quote -/// false`), and in the second form that word arrives as query text. -#[derive(Debug, Default)] -pub(crate) struct QueryInput { - /// The query words, in the order they were given. - query: Option>, - - /// `Some(true)` prefixes the quoted message with ` > `, `Some(false)` - /// seeds it verbatim, `None` means `--quote` was not given. - quote: Option, -} - -impl QueryInput { - /// Split the parsed arguments into the query and the quote seed. - fn resolve(args: QueryInputArgs, matches: &clap::ArgMatches) -> Self { - let QueryInputArgs { - mut query, - escaped_query, - quote, - } = args; - - let quote = match quote { - None => None, - Some(QuoteArg::Attached(prefixed)) => Some(prefixed), - // A bare `--quote` reads its value from the next word when that - // word is exactly `true` or `false`. Anything else there is query - // text, and the flag falls back to its default. - Some(QuoteArg::Bare) => Some(take_quote_value(&mut query, matches).unwrap_or(true)), - }; - - // The two halves are one query. They stay apart until here so that - // `--quote` above only ever sees the unescaped words, and stay in this - // order because `--` always comes last. - if let Some(escaped) = escaped_query { - query.get_or_insert_default().extend(escaped); - } - - Self { query, quote } - } -} - -/// Take the `true` / `false` word sitting directly after `--quote` out of the -/// query and return its value. -/// -/// Returns `None` — leaving the query untouched — when the flag is followed -/// by anything else. -/// Words given after `--` are never candidates: they land in a separate -/// argument that this never reads. -fn take_quote_value(query: &mut Option>, matches: &clap::ArgMatches) -> Option { - // clap counts a flag and its value as two separate indices, so the word - // directly after `--quote` sits one past the index of the flag's own - // (defaulted) value. - let after_quote = matches.index_of("quote")? + 1; - let position = matches - .indices_of("query")? - .position(|index| index == after_quote)?; - - let words = query.as_mut()?; - let value = words.get(position)?.parse::().ok()?; - - words.remove(position); - if words.is_empty() { - *query = None; - } - - Some(value) -} - -/// Argument declarations for [`QueryInput`]. -/// -/// [`QueryInput`] borrows these declarations and resolves the parsed values -/// itself; it is never constructed as a command's own arguments. -#[derive(Debug, clap::Args)] -struct QueryInputArgs { - /// The query to send. - /// If not provided, uses `$JP_EDITOR`, `$VISUAL` or `$EDITOR` to open edit - /// the query in an editor. - /// - /// A query consisting of a single `@path` value is read from that file. - query: Option>, - - /// Query words given after `--`. - /// - /// clap only fills this argument through the `--` separator, which makes it - /// the record of which words were escaped. - /// They are appended to `query` once `--quote` has been resolved, so `--` - /// shields a `true` / `false` word from being read as the flag's value. - #[arg(last = true, hide = true)] - escaped_query: Option>, - - /// Pre-fill the editor with the last assistant message quoted as a markdown - /// blockquote (each line prefixed with ` > `). - /// - /// Useful for inline replies: open `$EDITOR` with the assistant's last - /// response pre-quoted, then intersperse your replies between the quoted - /// lines (mutt/email style). - /// The complete buffer — quotes plus your replies — becomes your next - /// message. - /// - /// `--quote=false` seeds the message verbatim, without the ` > ` prefixes. - /// `--quote=true` is the same as a bare `--quote`. - /// Both values also work unattached (`--quote false`); any other word after - /// `--quote` stays part of the query, so `jp q --quote what now?` still - /// asks "what now?". - /// To ask a question that *is* `true` or `false`, put it after `--`. - /// - /// Forces the editor open by default; respects `--no-edit` / `--edit=false` - /// if explicitly suppressed, in which case the quoted text is sent as-is - /// and echoed to the terminal before the turn runs. - /// Composes with `--replay`: the quote is taken from the stream *after* the - /// replayed turn has been trimmed, i.e. the assistant message preceding the - /// turn being replayed. - /// - /// If no prior assistant message exists in this conversation, a warning is - /// emitted and the editor opens with whatever other content was seeded - /// (query, stdin, or empty). - #[arg( - long = "quote", - value_name = "BOOL", - num_args = 0..=1, - require_equals = true, - default_missing_value = "", - value_parser = parse_quote_arg, - )] - quote: Option, -} - -/// The `--quote` value as it was written on the command line. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum QuoteArg { - /// `--quote` with nothing attached. - Bare, - - /// `--quote=true` or `--quote=false`. - Attached(bool), -} - -/// Parse the `--quote` value. -/// -/// The empty string is what a bare `--quote` yields, since `require_equals` -/// keeps it from swallowing the next word and the flag falls back to its -/// `default_missing_value`. -fn parse_quote_arg(s: &str) -> std::result::Result { - match s { - "" => Ok(QuoteArg::Bare), - "true" => Ok(QuoteArg::Attached(true)), - "false" => Ok(QuoteArg::Attached(false)), - _ => Err("expected `true` or `false`".to_owned()), - } -} - -impl clap::Args for QueryInput { - fn augment_args(cmd: clap::Command) -> clap::Command { - QueryInputArgs::augment_args(cmd) - } - - fn augment_args_for_update(cmd: clap::Command) -> clap::Command { - QueryInputArgs::augment_args_for_update(cmd) - } -} - -impl clap::FromArgMatches for QueryInput { - fn from_arg_matches(matches: &clap::ArgMatches) -> std::result::Result { - QueryInputArgs::from_arg_matches(matches).map(|args| Self::resolve(args, matches)) - } - - fn update_from_arg_matches( - &mut self, - matches: &clap::ArgMatches, - ) -> std::result::Result<(), clap::Error> { - *self = Self::from_arg_matches(matches)?; - Ok(()) - } -} - -/// A single tool selection directive from the CLI. -/// -/// Directives are evaluated left-to-right, allowing users to compose tool sets -/// precisely (e.g. `--no-tools --tool=write --no-tools=fs_modify_file`). -#[derive(Debug, Clone, PartialEq, Eq)] -enum ToolDirective { - EnableAll, - DisableAll, - Enable(String), - Disable(String), -} - -impl ToolDirective { - /// Returns the single-tool directive as a string slice. - #[must_use] - fn as_single(&self) -> Option<&str> { - match self { - Self::Enable(name) | Self::Disable(name) => Some(name.as_str()), - _ => None, - } - } -} - -/// Ordered sequence of tool directives parsed from `--tool` and `--no-tools`. -/// -/// Implements manual [`clap::Args`] and [`clap::FromArgMatches`] to recover the -/// position of each flag value using [`ArgMatches::indices_of`], then merges -/// and sorts them by index into a single ordered list. -/// -/// [`ArgMatches::indices_of`]: clap::ArgMatches::indices_of -#[derive(Debug, Clone, Default)] -struct ToolDirectives(Vec); - -impl std::ops::Deref for ToolDirectives { - type Target = [ToolDirective]; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl clap::FromArgMatches for ToolDirectives { - fn from_arg_matches(matches: &clap::ArgMatches) -> std::result::Result { - let tool_values: Vec = matches - .get_many("tools") - .map(|v| v.cloned().collect()) - .unwrap_or_default(); - let tool_indices: Vec<_> = matches - .indices_of("tools") - .map(Iterator::collect) - .unwrap_or_default(); - - let no_tool_values: Vec = matches - .get_many("no_tools") - .map(|v| v.cloned().collect()) - .unwrap_or_default(); - let no_tool_indices: Vec<_> = matches - .indices_of("no_tools") - .map(Iterator::collect) - .unwrap_or_default(); - - let mut indexed = vec![]; - for (val, idx) in tool_values.into_iter().zip(tool_indices) { - if val.is_empty() { - indexed.push((idx, ToolDirective::EnableAll)); - continue; - } - - for name in split_list(&val, "tool name")? { - indexed.push((idx, ToolDirective::Enable(name))); - } - } - - for (val, idx) in no_tool_values.into_iter().zip(no_tool_indices) { - if val.is_empty() { - indexed.push((idx, ToolDirective::DisableAll)); - continue; - } - - for name in split_list(&val, "tool name")? { - indexed.push((idx, ToolDirective::Disable(name))); - } - } - - // A stable sort, so the names of a single flag keep the order they were - // written in: they all carry that flag's index. - indexed.sort_by_key(|(idx, _)| *idx); - Ok(Self(indexed.into_iter().map(|(_, d)| d).collect())) - } - - fn update_from_arg_matches( - &mut self, - matches: &clap::ArgMatches, - ) -> std::result::Result<(), clap::Error> { - *self = Self::from_arg_matches(matches)?; - Ok(()) - } -} - -impl clap::Args for ToolDirectives { - fn augment_args(cmd: clap::Command) -> clap::Command { - cmd.arg( - clap::Arg::new("tools") - .short('t') - .long("tool") - .alias("tools") - .help("The tool(s) to enable") - .long_help( - "The tool(s) to enable.\n\nIf an existing tool is configured with a matching \ - name, it is enabled for this query and every later one on the conversation; \ - use `--no-tool` to turn it back off.\n\nTo run a disabled tool just once, \ - use `--tool-use NAME` instead.\n\nIf no arguments are provided, every tool \ - that allows it is enabled; a tool set to `explicit` or `always` is \ - unaffected.\n\nName several tools at once by separating them with commas \ - (`--tool=read,write`), or by providing this flag multiple times. Flags are \ - evaluated left-to-right, so `--no-tools --tool=write` first disables \ - everything, then re-enables only 'write'.", - ) - .action(ArgAction::Append) - .num_args(0..=1) - // The values are split on commas by hand rather than with - // `value_delimiter(',')`, which splits before this empty string - // is read: an empty segment in `--tool=read,` would then be - // indistinguishable from a bare `--tool` and enable every tool. - .default_missing_value(""), - ) - .arg( - clap::Arg::new("no_tools") - .short('T') - .long("no-tool") - .alias("no-tools") - .help("Disable tool(s)") - .long_help( - "Disable tool(s).\n\nIf provided without a value, every tool that allows it \ - is disabled (a tool set to `explicit` or `always` is unaffected), otherwise \ - name the tools to disable, separated by commas (`--no-tool=read,write`) or \ - across repeated flags.\n\nThe change applies to this query and every later \ - one on the conversation; use `--tool` to turn tools back on. To suppress \ - tools for a single query, use `--no-tool-use`.\n\nFlags are evaluated \ - left-to-right together with `--tool`.", - ) - .action(ArgAction::Append) - .num_args(0..=1) - .default_missing_value(""), - ) - } - - fn augment_args_for_update(cmd: clap::Command) -> clap::Command { - Self::augment_args(cmd) - } -} - /// Fork a conversation and return the new conversation's lock. async fn fork_conversation( ctx: &mut Ctx, @@ -2483,6 +1878,7 @@ impl IntoPartialAppConfig for Query { ) -> std::result::Result> { let Self { model, + auth, template: _, schema: _, replay: _, @@ -2513,6 +1909,7 @@ impl IntoPartialAppConfig for Query { } = &self; apply_model(&mut partial, model.as_deref(), merged_config); + apply_auth(&mut partial, auth, merged_config)?; // Must run before tool-enable processing, which reads the injected // `enable` blocks. @@ -2621,6 +2018,92 @@ fn build_thread( Ok(thread_builder.build()?) } +/// Write `--auth` to the `auth` chain of the provider serving this turn. +/// +/// Runs after [`apply_model`], since the provider comes from the turn's model. +/// A provider that cannot be determined is an error: writing the chain to the +/// wrong one would silently do nothing. +fn apply_auth( + partial: &mut PartialAppConfig, + auth: &[AuthEntry], + merged_config: Option<&PartialAppConfig>, +) -> BoxedResult<()> { + if auth.is_empty() { + return Ok(()); + } + + let provider = active_provider(partial, merged_config).ok_or_else(|| { + format!( + "--auth needs to know which provider to bill, and the model for this turn does not \ + name one; pass `--model /`, or set the chain directly with `--cfg \ + providers.llm..auth={}`", + auth.iter() + .map(ToString::to_string) + .collect::>() + .join(",") + ) + })?; + + let auth = auth.to_vec(); + let llm = &mut partial.providers.llm; + match provider { + ProviderId::Anthropic => llm.anthropic.auth = Some(auth), + ProviderId::Cerebras => llm.cerebras.auth = Some(auth), + ProviderId::Deepseek => llm.deepseek.auth = Some(auth), + ProviderId::Google => llm.google.auth = Some(auth), + ProviderId::Openai => llm.openai.auth = Some(auth), + ProviderId::Openrouter => llm.openrouter.auth = Some(auth), + ProviderId::Vllm => llm.vllm.auth = Some(auth), + + provider @ (ProviderId::Llamacpp + | ProviderId::Ollama + | ProviderId::Test + | ProviderId::Xai) => { + return Err(format!( + "--auth is not supported for `{provider}`: it needs no credential" + ) + .into()); + } + } + + Ok(()) +} + +/// The provider serving this turn, if the config says which. +/// +/// Reads the CLI's `--model` first, then the config layers. +fn active_provider( + partial: &PartialAppConfig, + merged_config: Option<&PartialAppConfig>, +) -> Option { + let aliases = merged_config.map_or(&partial.providers.llm.aliases, |merged| { + &merged.providers.llm.aliases + }); + + [Some(partial), merged_config] + .into_iter() + .flatten() + .find_map(|config| provider_of(&config.assistant.model.id, aliases, 8)) +} + +/// Follow a model id, or an alias to the id it stands for, to its provider. +/// +/// `depth` bounds an alias chain that points at itself; the config pipeline +/// reports the cycle properly later. +fn provider_of( + id: &PartialModelIdOrAliasConfig, + aliases: &IndexMap, + depth: u8, +) -> Option { + match id { + PartialModelIdOrAliasConfig::Id(id) => id.provider, + PartialModelIdOrAliasConfig::Alias(alias) if depth > 0 => { + provider_of(aliases.get(alias.as_str())?, aliases, depth - 1) + } + PartialModelIdOrAliasConfig::Alias(_) => None, + } +} + /// Apply the CLI model configuration to the partial configuration. /// /// `model` is the raw `--model` value: an alias or a full `provider/name` ID. diff --git a/crates/jp_cli/src/cmd/query/agent_turn_tests.rs b/crates/jp_cli/src/cmd/query/agent_turn_tests.rs new file mode 100644 index 000000000..547fec189 --- /dev/null +++ b/crates/jp_cli/src/cmd/query/agent_turn_tests.rs @@ -0,0 +1,172 @@ +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use async_trait::async_trait; +use camino_tempfile::tempdir; +use datetime_literal::datetime; +use indexmap::IndexMap; +use jp_config::{ + AppConfig, Config as _, + assistant::tool_choice::ToolChoice, + conversation::tool::{PartialToolConfig, ToolConfig}, + model::id::Name, +}; +use jp_conversation::{ + Conversation, ConversationId, ConversationStream, + event::{ChatRequest, InquiryResponse, ToolCallResponse}, +}; +use jp_inquire::prompt::MockPromptBackend; +use jp_llm::{ + Error as LlmError, EventStream, Provider, + event::{Event, EventPart, FinishReason, ToolCallPart}, + model::ModelDetails, + query::{ChatQuery, QueryContext, QueryStream, ToolExecution}, +}; +use jp_mcp::{ + Client, + server::{BuiltinTool, builtin::BuiltinExecutors, http::connect, result::from_mcp}, +}; +use jp_printer::{OutputFormat, Printer}; +use jp_storage::backend::FsStorageBackend; +use jp_tool::{InvocationContext, Outcome, Question, ToolDefinition, ToolDocs}; +use jp_workspace::Workspace; +use rmcp::model::{CallToolRequestParams, Meta}; +use serde_json::{Map, Value, json}; +use tokio::time::{Duration, timeout}; + +use super::{PendingStreamTrim, ToolCoordinator, run_turn_loop}; +use crate::{ + access::approvals::ApprovalStore, cmd::query::tool::mcp_executor::TerminalExecutorSource, + signals::testing::detached_router, +}; + +struct InquiringTool(Arc); + +#[async_trait] +impl BuiltinTool for InquiringTool { + async fn execute(&self, _: &Value, answers: &IndexMap) -> Outcome { + self.0.fetch_add(1, Ordering::SeqCst); + if answers.get("confirm") == Some(&json!(true)) { + return "confirmed".into(); + } + Question::boolean("confirm", "Continue?").unwrap().into() + } +} + +struct AgentProvider { + starts: AtomicUsize, + storage: Arc, + conversation: ConversationId, + config: AppConfig, +} + +#[async_trait] +impl Provider for AgentProvider { + async fn model_details(&self, _: &Name) -> Result { + Ok(ModelDetails::empty("anthropic/test".parse().unwrap())) + } + async fn models(&self) -> Result, LlmError> { + Ok(vec![]) + } + async fn chat_completion_stream( + &self, + _: &ModelDetails, + _: ChatQuery, + ) -> Result { + panic!("the continuation must not become another provider request") + } + async fn start_query( + &self, + _: &ModelDetails, + _: ChatQuery, + context: QueryContext, + ) -> Result { + assert_eq!(self.starts.fetch_add(1, Ordering::SeqCst), 0); + let url = context.mcp_endpoint.unwrap(); + let client = connect(&url).await.unwrap(); + let storage = self.storage.clone(); + let id = self.conversation; + let config = self.config.clone(); + let stream = async_stream::stream! { + yield Ok(Event::ToolCallPending { id: "agent-call".into(), name: "http_tool".into() }); + yield Ok(Event::ToolCallPending { id: "agent-call-2".into(), name: "http_tool".into() }); + let stored = serde_json::from_str(&storage.read_test_events_raw(&id).unwrap()).unwrap(); + let events = ConversationStream::from_parts(json!({}), stored, &config.clone().into()).unwrap(); + assert_eq!(events.iter().filter_map(|event| event.event.as_tool_call_request()).count(), 0); + let mut params = CallToolRequestParams::new("http_tool"); + params.meta = Some(Meta(Map::from_iter([("test/agentId".into(), "agent-call".into())]))); + let peer = client.peer().clone(); + let call = tokio::spawn(async move { peer.call_tool(params).await }); + yield Ok(Event::Part { index: 0, part: EventPart::ToolCall(ToolCallPart::Start { id: "agent-call".into(), name: "http_tool".into() }), metadata: Map::new() }); + yield Ok(Event::Part { index: 0, part: EventPart::ToolCall(ToolCallPart::ArgumentChunk("{}".into())), metadata: Map::new() }); + yield Ok(Event::flush(0)); + yield Ok(Event::Finished(FinishReason::Completed)); + let result = call.await.unwrap().unwrap(); + assert_eq!(from_mcp(result).unwrap().to_text(), "confirmed"); + let mut second = CallToolRequestParams::new("http_tool"); + second.meta = Some(Meta(Map::from_iter([("test/agentId".into(), "agent-call-2".into())]))); + let peer = client.peer().clone(); + let call = tokio::spawn(async move { peer.call_tool(second).await }); + yield Ok(Event::Part { index: 1, part: EventPart::ToolCall(ToolCallPart::Start { id: "agent-call-2".into(), name: "http_tool".into() }), metadata: Map::new() }); + yield Ok(Event::Part { index: 1, part: EventPart::ToolCall(ToolCallPart::ArgumentChunk("{}".into())), metadata: Map::new() }); + yield Ok(Event::flush(1)); + yield Ok(Event::Finished(FinishReason::Completed)); + let result = call.await.unwrap().unwrap(); + assert_eq!(from_mcp(result).unwrap().to_text(), "confirmed"); + let stored = serde_json::from_str(&storage.read_test_events_raw(&id).unwrap()).unwrap(); + let events = ConversationStream::from_parts(json!({}), stored, &config.into()).unwrap(); + let responses = events.iter().filter_map(|event| event.event.as_tool_call_response()).cloned().collect::>(); + assert_eq!(responses, vec![ + ToolCallResponse { id: "agent-call".into(), result: Ok("confirmed".into()) }, + ToolCallResponse { id: "agent-call-2".into(), result: Ok("confirmed".into()) }, + ]); + yield Ok(Event::Part { index: 2, part: EventPart::Message("Finished.".into()), metadata: Map::new() }); + yield Ok(Event::flush(2)); + client.cancel().await.unwrap(); + yield Ok(Event::Finished(FinishReason::Completed)); + }; + Ok(QueryStream { + events: Box::pin(stream), + execution: ToolExecution::Agent { + correlation_key: "test/agentId", + }, + }) + } +} + +#[tokio::test] +async fn agent_continuation_waits_for_host_recording_without_resubmission() { + timeout(Duration::from_secs(10), async { + let temp = tempdir().unwrap(); + let root = temp.path(); + let mut config = AppConfig::new_test(); + let partial: PartialToolConfig = serde_json::from_value(json!({"source":"builtin","run":"unattended","style":{"hidden":true},"questions":{"confirm":{"answer":true}}})).unwrap(); + config.conversation.tools.insert("http_tool".into(), ToolConfig::from_partial(partial, vec![]).unwrap()); + let storage = Arc::new(FsStorageBackend::new(&root.join(".jp")).unwrap()); + let mut workspace = Workspace::in_memory(root).with_backend(storage.clone()); + let timestamp = datetime!(2026-09-11 12:00:00 Z); + let id = ConversationId::try_from(timestamp).unwrap(); + let conversation = Conversation { last_activated_at: timestamp, ..Conversation::default() }; + let lock = workspace.create_and_lock_conversation_with_id(id, conversation, config.clone().into(), None).unwrap(); + let definitions = vec![ToolDefinition { name: "http_tool".into(), docs: ToolDocs::default(), parameters: json!({"type":"object","properties":{}}) }]; + let count = Arc::new(AtomicUsize::new(0)); + let client = Client::default(); + let (source, owner) = TerminalExecutorSource::start(BuiltinExecutors::new().register("http_tool", InquiringTool(count.clone())), &definitions, &config.conversation.tools, Arc::new(ApprovalStore::default()), InvocationContext::default(), &client, root.to_owned()).await.unwrap(); + let provider = Arc::new(AgentProvider { starts: AtomicUsize::new(0), storage, conversation: id, config: config.clone() }); + let model = provider.model_details(&"test".parse().unwrap()).await.unwrap(); + let router = detached_router(); + let (printer, output, chrome) = Printer::memory(OutputFormat::TextPretty); + let printer = Arc::new(printer); + run_turn_loop(provider.clone(), &model, &config, &router, root, InvocationContext::default(), false, &[], &lock, ToolChoice::Auto, &definitions, printer.clone(), Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(source)), ChatRequest::from("Run the tool."), PendingStreamTrim::default(), router.turn_interrupt(lock.id())).await.unwrap(); + let answers = lock.events().iter().filter_map(|event| event.event.as_inquiry_response()).filter_map(|answer| match answer { InquiryResponse::Answered { answer, .. } => Some(answer.clone()), _ => None }).collect::>(); + assert_eq!(answers, vec![json!(true), json!(true)]); + assert_eq!(count.load(Ordering::SeqCst), 4); + assert_eq!(provider.starts.load(Ordering::SeqCst), 1); + printer.flush(); + assert_eq!(output.lock().as_str(), "Finished.\n\n"); + assert_eq!(chrome.lock().as_str(), "\n── \x1b[1mjp\x1b[0m \x1b[2m(anthropic/test)\x1b[0m ─────────────────────────────────────────────────────────\n\n"); + owner.shutdown().await.unwrap(); + }).await.unwrap(); +} diff --git a/crates/jp_cli/src/cmd/query/args.rs b/crates/jp_cli/src/cmd/query/args.rs new file mode 100644 index 000000000..d79907e42 --- /dev/null +++ b/crates/jp_cli/src/cmd/query/args.rs @@ -0,0 +1,336 @@ +//! The `query` arguments clap cannot derive on its own. +//! +//! Both types here exist because their flags depend on *where* a value sat on +//! the command line, which a derived `clap::Args` cannot see. +//! [`QueryInput`] has to know whether the word after `--quote` was its value or +//! the start of the query; [`ToolDirectives`] has to know the order `--tool` +//! and `--no-tool` were written in. +//! Everything else on `Query` is derived as usual. + +use clap::ArgAction; + +use crate::parser::split_list; + +/// The query text and the `--quote` seed. +/// +/// The two are parsed together because `--quote` accepts its value either +/// attached (`--quote=false`) or as the word right after the flag (`--quote +/// false`), and in the second form that word arrives as query text. +#[derive(Debug, Default)] +pub(crate) struct QueryInput { + /// The query words, in the order they were given. + pub(super) query: Option>, + + /// `Some(true)` prefixes the quoted message with ` > `, `Some(false)` + /// seeds it verbatim, `None` means `--quote` was not given. + pub(super) quote: Option, +} + +impl QueryInput { + /// Split the parsed arguments into the query and the quote seed. + fn resolve(args: QueryInputArgs, matches: &clap::ArgMatches) -> Self { + let QueryInputArgs { + mut query, + escaped_query, + quote, + } = args; + + let quote = match quote { + None => None, + Some(QuoteArg::Attached(prefixed)) => Some(prefixed), + // A bare `--quote` reads its value from the next word when that + // word is exactly `true` or `false`. Anything else there is query + // text, and the flag falls back to its default. + Some(QuoteArg::Bare) => Some(take_quote_value(&mut query, matches).unwrap_or(true)), + }; + + // The two halves are one query. They stay apart until here so that + // `--quote` above only ever sees the unescaped words, and stay in this + // order because `--` always comes last. + if let Some(escaped) = escaped_query { + query.get_or_insert_default().extend(escaped); + } + + Self { query, quote } + } +} + +/// Take the `true` / `false` word sitting directly after `--quote` out of the +/// query and return its value. +/// +/// Returns `None` — leaving the query untouched — when the flag is followed +/// by anything else. +/// Words given after `--` are never candidates: they land in a separate +/// argument that this never reads. +fn take_quote_value(query: &mut Option>, matches: &clap::ArgMatches) -> Option { + // clap counts a flag and its value as two separate indices, so the word + // directly after `--quote` sits one past the index of the flag's own + // (defaulted) value. + let after_quote = matches.index_of("quote")? + 1; + let position = matches + .indices_of("query")? + .position(|index| index == after_quote)?; + + let words = query.as_mut()?; + let value = words.get(position)?.parse::().ok()?; + + words.remove(position); + if words.is_empty() { + *query = None; + } + + Some(value) +} + +/// Argument declarations for [`QueryInput`]. +/// +/// [`QueryInput`] borrows these declarations and resolves the parsed values +/// itself; it is never constructed as a command's own arguments. +#[derive(Debug, clap::Args)] +struct QueryInputArgs { + /// The query to send. + /// If not provided, uses `$JP_EDITOR`, `$VISUAL` or `$EDITOR` to open edit + /// the query in an editor. + /// + /// A query consisting of a single `@path` value is read from that file. + query: Option>, + + /// Query words given after `--`. + /// + /// clap only fills this argument through the `--` separator, which makes it + /// the record of which words were escaped. + /// They are appended to `query` once `--quote` has been resolved, so `--` + /// shields a `true` / `false` word from being read as the flag's value. + #[arg(last = true, hide = true)] + escaped_query: Option>, + + /// Pre-fill the editor with the last assistant message quoted as a markdown + /// blockquote (each line prefixed with ` > `). + /// + /// Useful for inline replies: open `$EDITOR` with the assistant's last + /// response pre-quoted, then intersperse your replies between the quoted + /// lines (mutt/email style). + /// The complete buffer — quotes plus your replies — becomes your next + /// message. + /// + /// `--quote=false` seeds the message verbatim, without the ` > ` prefixes. + /// `--quote=true` is the same as a bare `--quote`. + /// Both values also work unattached (`--quote false`); any other word after + /// `--quote` stays part of the query, so `jp q --quote what now?` still + /// asks "what now?". + /// To ask a question that *is* `true` or `false`, put it after `--`. + /// + /// Forces the editor open by default; respects `--no-edit` / `--edit=false` + /// if explicitly suppressed, in which case the quoted text is sent as-is + /// and echoed to the terminal before the turn runs. + /// Composes with `--replay`: the quote is taken from the stream *after* the + /// replayed turn has been trimmed, i.e. the assistant message preceding the + /// turn being replayed. + /// + /// If no prior assistant message exists in this conversation, a warning is + /// emitted and the editor opens with whatever other content was seeded + /// (query, stdin, or empty). + #[arg( + long = "quote", + value_name = "BOOL", + num_args = 0..=1, + require_equals = true, + default_missing_value = "", + value_parser = parse_quote_arg, + )] + quote: Option, +} + +/// The `--quote` value as it was written on the command line. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum QuoteArg { + /// `--quote` with nothing attached. + Bare, + + /// `--quote=true` or `--quote=false`. + Attached(bool), +} + +/// Parse the `--quote` value. +/// +/// The empty string is what a bare `--quote` yields, since `require_equals` +/// keeps it from swallowing the next word and the flag falls back to its +/// `default_missing_value`. +fn parse_quote_arg(s: &str) -> Result { + match s { + "" => Ok(QuoteArg::Bare), + "true" => Ok(QuoteArg::Attached(true)), + "false" => Ok(QuoteArg::Attached(false)), + _ => Err("expected `true` or `false`".to_owned()), + } +} + +impl clap::Args for QueryInput { + fn augment_args(cmd: clap::Command) -> clap::Command { + QueryInputArgs::augment_args(cmd) + } + + fn augment_args_for_update(cmd: clap::Command) -> clap::Command { + QueryInputArgs::augment_args_for_update(cmd) + } +} + +impl clap::FromArgMatches for QueryInput { + fn from_arg_matches(matches: &clap::ArgMatches) -> Result { + QueryInputArgs::from_arg_matches(matches).map(|args| Self::resolve(args, matches)) + } + + fn update_from_arg_matches(&mut self, matches: &clap::ArgMatches) -> Result<(), clap::Error> { + *self = Self::from_arg_matches(matches)?; + Ok(()) + } +} + +/// A single tool selection directive from the CLI. +/// +/// Directives are evaluated left-to-right, allowing users to compose tool sets +/// precisely (e.g. `--no-tools --tool=write --no-tools=fs_modify_file`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ToolDirective { + EnableAll, + DisableAll, + Enable(String), + Disable(String), +} + +impl ToolDirective { + /// Returns the single-tool directive as a string slice. + #[must_use] + pub(crate) fn as_single(&self) -> Option<&str> { + match self { + Self::Enable(name) | Self::Disable(name) => Some(name.as_str()), + _ => None, + } + } +} + +/// Ordered sequence of tool directives parsed from `--tool` and `--no-tools`. +/// +/// Implements manual [`clap::Args`] and [`clap::FromArgMatches`] to recover the +/// position of each flag value using [`ArgMatches::indices_of`], then merges +/// and sorts them by index into a single ordered list. +/// +/// [`ArgMatches::indices_of`]: clap::ArgMatches::indices_of +#[derive(Debug, Clone, Default)] +pub(crate) struct ToolDirectives(pub(super) Vec); + +impl std::ops::Deref for ToolDirectives { + type Target = [ToolDirective]; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl clap::FromArgMatches for ToolDirectives { + fn from_arg_matches(matches: &clap::ArgMatches) -> Result { + let tool_values: Vec = matches + .get_many("tools") + .map(|v| v.cloned().collect()) + .unwrap_or_default(); + let tool_indices: Vec<_> = matches + .indices_of("tools") + .map(Iterator::collect) + .unwrap_or_default(); + + let no_tool_values: Vec = matches + .get_many("no_tools") + .map(|v| v.cloned().collect()) + .unwrap_or_default(); + let no_tool_indices: Vec<_> = matches + .indices_of("no_tools") + .map(Iterator::collect) + .unwrap_or_default(); + + let mut indexed = vec![]; + for (val, idx) in tool_values.into_iter().zip(tool_indices) { + if val.is_empty() { + indexed.push((idx, ToolDirective::EnableAll)); + continue; + } + + for name in split_list(&val, "tool name")? { + indexed.push((idx, ToolDirective::Enable(name))); + } + } + + for (val, idx) in no_tool_values.into_iter().zip(no_tool_indices) { + if val.is_empty() { + indexed.push((idx, ToolDirective::DisableAll)); + continue; + } + + for name in split_list(&val, "tool name")? { + indexed.push((idx, ToolDirective::Disable(name))); + } + } + + // A stable sort, so the names of a single flag keep the order they were + // written in: they all carry that flag's index. + indexed.sort_by_key(|(idx, _)| *idx); + Ok(Self(indexed.into_iter().map(|(_, d)| d).collect())) + } + + fn update_from_arg_matches(&mut self, matches: &clap::ArgMatches) -> Result<(), clap::Error> { + *self = Self::from_arg_matches(matches)?; + Ok(()) + } +} + +impl clap::Args for ToolDirectives { + fn augment_args(cmd: clap::Command) -> clap::Command { + cmd.arg( + clap::Arg::new("tools") + .short('t') + .long("tool") + .alias("tools") + .help("The tool(s) to enable") + .long_help( + "The tool(s) to enable.\n\nIf an existing tool is configured with a matching \ + name, it is enabled for this query and every later one on the conversation; \ + use `--no-tool` to turn it back off.\n\nTo run a disabled tool just once, \ + use `--tool-use NAME` instead.\n\nIf no arguments are provided, every tool \ + that allows it is enabled; a tool set to `explicit` or `always` is \ + unaffected.\n\nName several tools at once by separating them with commas \ + (`--tool=read,write`), or by providing this flag multiple times. Flags are \ + evaluated left-to-right, so `--no-tools --tool=write` first disables \ + everything, then re-enables only 'write'.", + ) + .action(ArgAction::Append) + .num_args(0..=1) + // The values are split on commas by hand rather than with + // `value_delimiter(',')`, which splits before this empty string + // is read: an empty segment in `--tool=read,` would then be + // indistinguishable from a bare `--tool` and enable every tool. + .default_missing_value(""), + ) + .arg( + clap::Arg::new("no_tools") + .short('T') + .long("no-tool") + .alias("no-tools") + .help("Disable tool(s)") + .long_help( + "Disable tool(s).\n\nIf provided without a value, every tool that allows it \ + is disabled (a tool set to `explicit` or `always` is unaffected), otherwise \ + name the tools to disable, separated by commas (`--no-tool=read,write`) or \ + across repeated flags.\n\nThe change applies to this query and every later \ + one on the conversation; use `--tool` to turn tools back on. To suppress \ + tools for a single query, use `--no-tool-use`.\n\nFlags are evaluated \ + left-to-right together with `--tool`.", + ) + .action(ArgAction::Append) + .num_args(0..=1) + .default_missing_value(""), + ) + } + + fn augment_args_for_update(cmd: clap::Command) -> clap::Command { + Self::augment_args(cmd) + } +} diff --git a/crates/jp_cli/src/cmd/query/interrupt/signals.rs b/crates/jp_cli/src/cmd/query/interrupt/signals.rs index 867751873..45ba15883 100644 --- a/crates/jp_cli/src/cmd/query/interrupt/signals.rs +++ b/crates/jp_cli/src/cmd/query/interrupt/signals.rs @@ -242,6 +242,27 @@ pub enum ToolInterruptResult { PromptFailed, } +/// What the tool interrupt menu needs in order to run. +/// +/// The menu is the only part of an execution phase that talks to the terminal +/// on its own, so its dependencies travel together and reach nothing else. +pub struct InterruptUi<'a> { + /// Records the state transition the chosen action implies. + pub turn_coordinator: &'a mut TurnCoordinator, + + /// Where the menu draws. + pub printer: &'a Printer, + + /// Reads the user's choice; a test supplies a scripted one. + pub backend: &'a dyn PromptBackend, + + /// Opens the editor a "Stop & respond" reply may use. + pub editor: Option>, + + /// Which editing style that reply uses. + pub edit_mode: ReplyEditMode, +} + /// Handle a Ctrl-C interrupt notification received during tool execution. /// /// Applies the configured tool interrupt behavior: the menu is shown only when @@ -252,20 +273,10 @@ pub enum ToolInterruptResult { /// question, result edit), the interrupt is declined: the active prompt handles /// Ctrl+C itself, and the caller should pass the notification down the handler /// stack. -/// -/// # Arguments -/// -/// - `is_prompting` - Whether any tool is currently showing an interactive -/// prompt. -/// - `backend` - Allows injecting a mock prompt backend for testing. pub fn handle_tool_interrupt( cancellation_token: &CancellationToken, - turn_coordinator: &mut TurnCoordinator, is_prompting: bool, - printer: &Printer, - backend: &dyn PromptBackend, - editor: Option>, - edit_mode: ReplyEditMode, + ui: &mut InterruptUi<'_>, config: &ToolInterruptConfig, ) -> ToolInterruptResult { if is_prompting { @@ -273,8 +284,8 @@ pub fn handle_tool_interrupt( return ToolInterruptResult::Declined; } - let action = InterruptHandler::with_backend(backend, editor, edit_mode) - .handle_tool_interrupt(config, printer); + let action = InterruptHandler::with_backend(ui.backend, ui.editor.clone(), ui.edit_mode) + .handle_tool_interrupt(config, ui.printer); debug!(?action, "Tool interrupt resolved."); // A menu that never ran decided nothing: the running tools are left alone @@ -284,16 +295,14 @@ pub fn handle_tool_interrupt( } // Notify the state machine (reserved for future state transitions). - turn_coordinator.handle_tool_interrupt(&action); + ui.turn_coordinator.handle_tool_interrupt(&action); let result = match action { InterruptAction::RestartTool => { info!("Restarting tool execution"); - cancellation_token.cancel(); ToolInterruptResult::Restart } InterruptAction::ToolCancelled { response, exit } => { - cancellation_token.cancel(); ToolInterruptResult::Cancelled { response, exit } } InterruptAction::Escalate => { diff --git a/crates/jp_cli/src/cmd/query/interrupt/signals_tests.rs b/crates/jp_cli/src/cmd/query/interrupt/signals_tests.rs index 02493b8cf..3ab11f7d6 100644 --- a/crates/jp_cli/src/cmd/query/interrupt/signals_tests.rs +++ b/crates/jp_cli/src/cmd/query/interrupt/signals_tests.rs @@ -469,19 +469,21 @@ fn tool_interrupt_restart_returns_restart() { let result = handle_tool_interrupt( &token, - &mut turn_coordinator, false, // not prompting - &printer, - &backend, - None, - ReplyEditMode::Emacs, + &mut InterruptUi { + turn_coordinator: &mut turn_coordinator, + printer: &printer, + backend: &backend, + editor: None, + edit_mode: ReplyEditMode::Emacs, + }, &tool_prompt(), ); assert_eq!(result, ToolInterruptResult::Restart); assert!( - token.is_cancelled(), - "Restart should cancel current execution" + !token.is_cancelled(), + "The coordinator must preserve MCP calls before cancelling workers" ); } @@ -502,12 +504,14 @@ fn tool_interrupt_cancelled_empty_reply_has_no_custom_message() { let result = handle_tool_interrupt( &token, - &mut turn_coordinator, false, // not prompting - &printer, - &backend, - None, - ReplyEditMode::Emacs, + &mut InterruptUi { + turn_coordinator: &mut turn_coordinator, + printer: &printer, + backend: &backend, + editor: None, + edit_mode: ReplyEditMode::Emacs, + }, &tool_prompt(), ); @@ -519,7 +523,10 @@ fn tool_interrupt_cancelled_empty_reply_has_no_custom_message() { }, "Expected Cancelled without a custom message, got {result:?}", ); - assert!(token.is_cancelled(), "Cancel should stop current execution"); + assert!( + !token.is_cancelled(), + "The coordinator must hold MCP calls before cancelling workers" + ); } #[test] @@ -537,12 +544,14 @@ fn tool_interrupt_cancelled_with_custom_response() { let result = handle_tool_interrupt( &token, - &mut turn_coordinator, false, // not prompting - &printer, - &backend, - None, - ReplyEditMode::Emacs, + &mut InterruptUi { + turn_coordinator: &mut turn_coordinator, + printer: &printer, + backend: &backend, + editor: None, + edit_mode: ReplyEditMode::Emacs, + }, &tool_prompt(), ); @@ -550,7 +559,10 @@ fn tool_interrupt_cancelled_with_custom_response() { response: Some("wrong tool, use grep instead".into()), exit: false }); - assert!(token.is_cancelled(), "Cancel should stop current execution"); + assert!( + !token.is_cancelled(), + "The coordinator must hold MCP calls before cancelling workers" + ); } #[test] @@ -566,12 +578,14 @@ fn tool_interrupt_resume_continues_without_cancel() { let result = handle_tool_interrupt( &token, - &mut turn_coordinator, false, // not prompting - &printer, - &backend, - None, - ReplyEditMode::Emacs, + &mut InterruptUi { + turn_coordinator: &mut turn_coordinator, + printer: &printer, + backend: &backend, + editor: None, + edit_mode: ReplyEditMode::Emacs, + }, &tool_prompt(), ); @@ -596,12 +610,14 @@ fn tool_interrupt_declined_when_prompting() { let result = handle_tool_interrupt( &token, - &mut turn_coordinator, true, // prompting - &printer, - &backend, - None, - ReplyEditMode::Emacs, + &mut InterruptUi { + turn_coordinator: &mut turn_coordinator, + printer: &printer, + backend: &backend, + editor: None, + edit_mode: ReplyEditMode::Emacs, + }, &tool_prompt(), ); @@ -627,24 +643,23 @@ fn tool_interrupt_handled_when_not_prompting() { let result = handle_tool_interrupt( &token, - &mut turn_coordinator, false, // not prompting - &printer, - &backend, - None, - ReplyEditMode::Emacs, + &mut InterruptUi { + turn_coordinator: &mut turn_coordinator, + printer: &printer, + backend: &backend, + editor: None, + edit_mode: ReplyEditMode::Emacs, + }, &tool_prompt(), ); - // Should process the interrupt and cancel + // The menu runs when no prompt is active; the coordinator, not this + // handler, cancels the running tools. assert!( matches!(result, ToolInterruptResult::Cancelled { .. }), "Expected Cancelled variant when not prompting, got {result:?}" ); - assert!( - token.is_cancelled(), - "Should cancel when no prompt is active" - ); } #[test] @@ -661,12 +676,14 @@ fn tool_interrupt_menu_cancel_escalates() { let result = handle_tool_interrupt( &token, - &mut turn_coordinator, false, // not prompting - &printer, - &backend, - None, - ReplyEditMode::Emacs, + &mut InterruptUi { + turn_coordinator: &mut turn_coordinator, + printer: &printer, + backend: &backend, + editor: None, + edit_mode: ReplyEditMode::Emacs, + }, &tool_prompt(), ); diff --git a/crates/jp_cli/src/cmd/query/mcp_startup.rs b/crates/jp_cli/src/cmd/query/mcp_startup.rs new file mode 100644 index 000000000..ae569ac10 --- /dev/null +++ b/crates/jp_cli/src/cmd/query/mcp_startup.rs @@ -0,0 +1,234 @@ +//! Waiting for configured MCP servers to start, and saying so on stderr. +//! +//! [`await_mcp_servers`] drives the wait and owns the status region it shows +//! while it runs. +//! [`report_skipped_servers`] is the other half: an optional server that failed +//! does not fail the query, so the tools it backed go missing unless the query +//! says which ones. + +use std::{collections::HashMap, fmt::Write as _, sync::Arc, time::Duration}; + +use crossterm::style::Stylize as _; +use jp_config::{AppConfig, conversation::tool::ToolSource, style::mcp_startup::McpStartupConfig}; +use jp_mcp::{StartupSet, id::McpServerId}; +use jp_printer::{LineSink, PrintableExt as _, Printer, RegionStyle, StatusRegion}; +use jp_term::width::{display_width, truncate_to_width}; +use tokio::sync::broadcast::error::RecvError; + +use crate::{cmd, render::tool::output_lines}; + +/// Wait for background MCP server startups to complete. +/// +/// Shows an aggregate status row on stderr once the wait exceeds the configured +/// delay, updating the listed server names as startups finish, with a rolling +/// window of the servers' own stderr above it. +/// Servers that finish within the delay never trigger the row. +/// +/// Returns the optional servers that failed and were skipped, so the caller can +/// account for the tools that went with them. +/// A required server's failure is returned as an error instead; the rows are +/// erased on the way out, so it renders on a clean line. +pub(super) async fn await_mcp_servers( + mut startup: StartupSet, + config: McpStartupConfig, + printer: Arc, +) -> Result, cmd::Error> { + if startup.joins.is_empty() { + return Ok(Vec::new()); + } + + let region = claim_region(&printer, &config); + region.set_detail(status(&startup.pending)); + + // One sink per pending server, dropped the moment that server's join + // completes. The forwarder behind the channel runs until the *server* + // exits, which is long after it finished starting; a sink left open would + // let a started server's operational logging evict the build output of one + // still compiling. + let mut sinks: HashMap = startup + .pending + .iter() + .map(|id| (id.clone(), region.source(id.as_str()))) + .collect(); + + let mut skipped = Vec::new(); + let mut lines_open = true; + + let result = loop { + tokio::select! { + line = startup.stderr.recv(), if lines_open => match line { + Ok((id, text)) => if let Some(sink) = sinks.get(&id) { + sink.push(text); + }, + // The window shows the most recent lines by definition, so + // falling behind costs nothing worth reporting. + Err(RecvError::Lagged(_)) => {} + Err(RecvError::Closed) => lines_open = false, + }, + joined = startup.joins.join_next() => match joined { + None => break Ok(()), + Some(Err(error)) => break Err(cmd::Error::from(error)), + Some(Ok(Err(error))) => break Err(cmd::Error::from(error)), + Some(Ok(Ok(outcome))) => { + let id = outcome.id(); + sinks.remove(id); + startup.pending.retain(|pending| pending != id); + if outcome.was_skipped() { + skipped.push(id.clone()); + } + if !startup.pending.is_empty() { + region.set_detail(status(&startup.pending)); + } + } + }, + } + }; + + result.map(|()| skipped) +} + +/// Report optional MCP servers that failed to start. +/// +/// A skipped server completes the wait successfully, so without this the query +/// quietly loses tools: the `warn!` explaining why goes to the trace log, which +/// is discarded unless the run itself fails. +/// +/// Emitted whatever `style.mcp_startup.show` and `stderr_rows` say. +/// Those keys gate progress display; gating a failure report behind them would +/// reproduce the silence this closes. +/// +/// `--format json` gets the parts rather than a sentence about them: a program +/// deciding what to do about a missing server reads `server` and `tools`, and +/// can render its own prose from them if it wants any. +pub(super) fn report_skipped_servers( + printer: &Printer, + config: &AppConfig, + skipped: &[McpServerId], +) { + for id in skipped { + let tools = tools_backed_by(config, id); + + if printer.format().is_json() { + printer.println_raw(skipped_server_record(printer, id, &tools).to_err()); + continue; + } + + let mut line = format!("Optional MCP server '{id}' did not start"); + if !tools.is_empty() { + let _err = write!(line, "; unavailable tools: {}", tools.join(", ")); + } + line.push_str(" (run with -v for the reason)"); + + printer.eprintln(line.yellow().to_string()); + } +} + +/// Serialize one skipped-server report, indented when the format asks for it. +fn skipped_server_record(printer: &Printer, id: &McpServerId, tools: &[String]) -> String { + let record = serde_json::json!({ + "event": "mcp_server_unavailable", + "server": id.as_str(), + "tools": tools, + }); + + if printer.format().is_json_pretty() { + serde_json::to_string_pretty(&record) + } else { + serde_json::to_string(&record) + } + .unwrap_or_else(|_| record.to_string()) +} + +/// Names of the enabled tools sourced from `server`. +/// +/// Sorted, so the report reads the same way twice. +fn tools_backed_by(config: &AppConfig, server: &McpServerId) -> Vec { + let mut names: Vec = config + .conversation + .tools + .iter() + .filter(|(_, tool)| tool.is_enabled()) + .filter(|(_, tool)| match tool.source() { + ToolSource::Mcp { server: name, .. } => &McpServerId::new(name.as_str()) == server, + _ => false, + }) + .map(|(name, _)| name.to_string()) + .collect(); + + names.sort(); + names +} + +/// Claim the status region for the MCP server startup wait. +/// +/// Returns an inert region when `style.mcp_startup.show` is off, or when the +/// terminal cannot carry one. +fn claim_region(printer: &Printer, config: &McpStartupConfig) -> StatusRegion { + if !config.show { + return StatusRegion::inert(); + } + + // The row bounds itself rather than letting the region cut its tail: the + // elapsed time lives at the end, and a long server list would take it with + // it. + let columns = printer.chrome_columns(); + + printer.status_region( + RegionStyle::new( + Duration::from_secs(config.delay_secs.into()), + Duration::from_millis(config.interval_ms.into()), + move |secs, detail| line(secs, detail, columns), + ) + .with_output(output_lines(config.stderr_rows)), + ) +} + +/// Render the MCP startup status row for `secs` elapsed and `status`, bounding +/// the visible text to `width` columns when known. +/// +/// Truncation falls on the server-list fragment only: the ` ⏱ Starting ` +/// prefix and the ` {secs:.1}s ` timer suffix are always preserved, so the +/// elapsed time keeps moving even when a long list overflows. +/// A terminal too narrow for even the prefix and suffix falls back to a bounded +/// `⏱ {secs:.1}s`. +fn line(secs: f64, status: Option<&str>, width: Option) -> String { + let status = status.unwrap_or("MCP servers"); + let full = format!("⏱ Starting {status}… {secs:.1}s"); + match width { + Some(w) if display_width(&full) > usize::from(w) => { + let w = usize::from(w); + let prefix = "⏱ Starting "; + let suffix = format!(" {secs:.1}s"); + let reserved = display_width(prefix) + display_width(&suffix); + if w <= reserved { + truncate_to_width(&format!("⏱ {secs:.1}s"), w) + } else { + let status = truncate_to_width(status, w - reserved); + format!("{prefix}{status}{suffix}") + } + } + _ => full, + } +} + +/// Render the pending-server fragment for the MCP startup timer line. +/// +/// One server renders as `MCP server bookworm`; several render as `2 MCP +/// servers (bookworm, grizzly)`. +fn status(pending: &[McpServerId]) -> String { + match pending { + [id] => format!("MCP server {id}"), + ids => format!( + "{} MCP servers ({})", + ids.len(), + ids.iter() + .map(McpServerId::as_str) + .collect::>() + .join(", ") + ), + } +} + +#[cfg(test)] +#[path = "mcp_startup_tests.rs"] +mod tests; diff --git a/crates/jp_cli/src/cmd/query/mcp_startup_tests.rs b/crates/jp_cli/src/cmd/query/mcp_startup_tests.rs new file mode 100644 index 000000000..b925eed85 --- /dev/null +++ b/crates/jp_cli/src/cmd/query/mcp_startup_tests.rs @@ -0,0 +1,478 @@ +use std::sync::Arc; + +use jp_config::{ + AppConfig, + conversation::tool::{PartialEnableConfig, PartialToolConfig}, + style::stderr_rows::{RowCount, StderrRows}, + util::build, +}; +use jp_mcp::{Startup, StderrLine}; +use jp_printer::{OutputFormat, Printer, SharedBuffer, TerminalCapability}; +use jp_term::width::display_width; +use tokio::sync::broadcast; + +use super::*; + +#[test] +fn status_names_a_single_server() { + assert_eq!( + status(&[McpServerId::new("bookworm")]), + "MCP server bookworm" + ); +} + +#[test] +fn status_counts_and_lists_several_servers() { + assert_eq!( + status(&[McpServerId::new("bookworm"), McpServerId::new("grizzly")]), + "2 MCP servers (bookworm, grizzly)" + ); +} + +/// Timer settings that render immediately, so tests don't wait out a delay. +fn immediate_config() -> McpStartupConfig { + McpStartupConfig { + show: true, + delay_secs: 0, + interval_ms: 10, + // Most of these cases assert on the status row alone; the ones that + // exercise the window override this. + stderr_rows: StderrRows::Off, + } +} + +/// A startup wait that shows two window rows above the status row. +fn windowed_config() -> McpStartupConfig { + McpStartupConfig { + stderr_rows: StderrRows::Fixed(RowCount { rows: 2 }), + ..immediate_config() + } +} + +/// A startup set over `joins`, plus the sender a test can feed stderr through. +/// +/// Callers that don't exercise the window drop the sender, which closes the +/// channel; the wait treats that as "no more lines" rather than an error. +fn startup_set( + joins: tokio::task::JoinSet>, + pending: Vec, +) -> (StartupSet, broadcast::Sender) { + let (tx, rx) = broadcast::channel(64); + + ( + StartupSet { + joins, + pending, + stderr: rx, + }, + tx, + ) +} + +/// Poll `err` until `needle` appears, failing after a hard timeout. +/// +/// Synchronizes on the rendered output instead of a fixed sleep: the timer +/// writes frames from its own task, so tests wait for the frame to land rather +/// than guessing how long that takes. +async fn wait_for_frame(err: &SharedBuffer, needle: &str) { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while !err.lock().contains(needle) { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("frame {needle:?} never rendered")); +} + +/// An `AppConfig` whose `search` tool is backed by the `bookworm` MCP server. +fn config_with_mcp_tool(enabled: bool) -> AppConfig { + let mut partial = AppConfig::new_test().to_partial(); + partial + .conversation + .tools + .tools + .insert("search".to_owned(), PartialToolConfig { + source: Some(ToolSource::Mcp { + server: "bookworm".to_owned(), + tool: None, + }), + enable: Some(PartialEnableConfig { + state: Some(enabled), + ..PartialEnableConfig::default() + }), + ..PartialToolConfig::default() + }); + + build(partial).expect("the fixture config resolves") +} + +#[tokio::test] +async fn drains_all_startups() { + let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); + + let mut joins = tokio::task::JoinSet::new(); + joins.spawn(async { Ok(Startup::Ready(McpServerId::new("bookworm"))) }); + joins.spawn(async { Ok(Startup::Ready(McpServerId::new("grizzly"))) }); + let (startup, _lines) = startup_set(joins, vec![ + McpServerId::new("bookworm"), + McpServerId::new("grizzly"), + ]); + + let skipped = await_mcp_servers(startup, immediate_config(), Arc::new(printer)) + .await + .expect("all startups succeed"); + + assert!(skipped.is_empty(), "no server was skipped"); +} + +#[tokio::test] +async fn propagates_startup_error() { + let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); + + let mut joins = tokio::task::JoinSet::new(); + joins.spawn(async { Err(jp_mcp::Error::UnknownServer(McpServerId::new("bookworm"))) }); + let (startup, _lines) = startup_set(joins, vec![McpServerId::new("bookworm")]); + + let error = await_mcp_servers(startup, immediate_config(), Arc::new(printer)) + .await + .expect_err("a failed required server must fail the wait"); + + assert_eq!(error.message.as_deref(), Some("MCP error")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn shows_and_clears_the_timer_line() { + let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); + let printer = Arc::new(printer.with_terminal(TerminalCapability::interactive(Some(80)))); + + // Hold the startup window open until the test releases it, so the timer + // is guaranteed to tick while the server is still "starting". + let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>(); + let mut joins = tokio::task::JoinSet::new(); + joins.spawn(async move { + release_rx.await.ok(); + Ok(Startup::Ready(McpServerId::new("bookworm"))) + }); + let (startup, _lines) = startup_set(joins, vec![McpServerId::new("bookworm")]); + + let wait = tokio::spawn(await_mcp_servers( + startup, + immediate_config(), + printer.clone(), + )); + + // Let a few ticks land before releasing the startup. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + release_tx.send(()).expect("wait task is still running"); + wait.await + .expect("task did not panic") + .expect("startup succeeds"); + printer.flush(); + + let chrome = err.lock(); + assert!( + chrome.contains("⏱ Starting MCP server bookworm…"), + "timer line should name the pending server.\nChrome:\n{chrome}" + ); + assert!( + chrome.ends_with("\r\x1b[K"), + "finishing the wait must leave the line cleared.\nChrome:\n{chrome}" + ); +} + +#[test] +fn skipped_server_report_names_the_tools_that_went_with_it() { + let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); + + report_skipped_servers(&printer, &config_with_mcp_tool(true), &[McpServerId::new( + "bookworm", + )]); + printer.flush(); + + let chrome = err.lock(); + assert!( + chrome.contains("Optional MCP server 'bookworm' did not start"), + "the report must name the server.\nChrome:\n{chrome}" + ); + assert!( + chrome.contains("unavailable tools: search"), + "the report must name the tools that went with it.\nChrome:\n{chrome}" + ); + assert!( + chrome.contains("-v"), + "the report must point at where the reason lives.\nChrome:\n{chrome}" + ); +} + +#[test] +fn skipped_server_report_is_ndjson_under_json_format() { + let (printer, _out, err) = Printer::memory(OutputFormat::Json); + + report_skipped_servers(&printer, &config_with_mcp_tool(true), &[McpServerId::new( + "bookworm", + )]); + printer.flush(); + + let chrome = err.lock().clone(); + let parsed: serde_json::Value = + serde_json::from_str(chrome.trim()).expect("chrome is one NDJSON record"); + + assert_eq!(parsed["event"], "mcp_server_unavailable"); + assert_eq!(parsed["server"], "bookworm"); + assert_eq!(parsed["tools"][0], "search"); +} + +#[test] +fn skipped_server_report_skips_disabled_tools() { + let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); + + report_skipped_servers(&printer, &config_with_mcp_tool(false), &[McpServerId::new( + "bookworm", + )]); + printer.flush(); + + let chrome = err.lock(); + assert!( + chrome.contains("Optional MCP server 'bookworm' did not start"), + "the server is still reported.\nChrome:\n{chrome}" + ); + assert!( + !chrome.contains("unavailable tools"), + "a tool that was already off did not become unavailable.\nChrome:\n{chrome}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn shows_server_stderr_while_it_starts() { + let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); + let printer = Arc::new( + printer.with_terminal(TerminalCapability::interactive(Some(80)).with_rows(Some(24))), + ); + + let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>(); + let mut joins = tokio::task::JoinSet::new(); + joins.spawn(async move { + release_rx.await.ok(); + Ok(Startup::Ready(McpServerId::new("bookworm"))) + }); + let (startup, lines) = startup_set(joins, vec![McpServerId::new("bookworm")]); + + let wait = tokio::spawn(await_mcp_servers( + startup, + windowed_config(), + printer.clone(), + )); + + lines + .send((McpServerId::new("bookworm"), "Compiling serde".to_owned())) + .expect("the wait holds a receiver"); + wait_for_frame(&err, "Compiling serde").await; + + release_tx.send(()).expect("wait task is still running"); + wait.await + .expect("task did not panic") + .expect("startup succeeds"); + printer.flush(); + + let chrome = err.lock(); + assert!( + chrome.contains("⏱ Starting MCP server bookworm…"), + "the status row still names the pending server.\nChrome:\n{chrome}" + ); + assert!( + !chrome.contains("[bookworm]"), + "a single source renders verbatim, without a label.\nChrome:\n{chrome}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn window_lines_are_labelled_once_two_servers_contribute() { + let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); + let printer = Arc::new( + printer.with_terminal(TerminalCapability::interactive(Some(80)).with_rows(Some(24))), + ); + + let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>(); + let mut joins = tokio::task::JoinSet::new(); + joins.spawn(async move { + release_rx.await.ok(); + Ok(Startup::Ready(McpServerId::new("bookworm"))) + }); + let (startup, lines) = startup_set(joins, vec![ + McpServerId::new("bookworm"), + McpServerId::new("grizzly"), + ]); + + let wait = tokio::spawn(await_mcp_servers( + startup, + windowed_config(), + printer.clone(), + )); + + // Interleaved output from two sources is worse than none unlabelled: it + // misattributes progress. + lines + .send((McpServerId::new("bookworm"), "Compiling serde".to_owned())) + .expect("the wait holds a receiver"); + lines + .send((McpServerId::new("grizzly"), "Compiling tantivy".to_owned())) + .expect("the wait holds a receiver"); + // Labelling only starts once the window holds two sources, so the first + // label appearing means both lines have landed. + wait_for_frame(&err, "[bookworm]").await; + + release_tx.send(()).expect("wait task is still running"); + wait.await + .expect("task did not panic") + .expect("startup succeeds"); + printer.flush(); + + // The label's own colour is `jp_printer`'s business; what matters here is + // that each line carries its source's name, padded to line up, and that the + // colour closes before the source's own text starts. + let chrome = err.lock(); + assert!( + chrome.contains("[bookworm]\x1b[39m Compiling serde"), + "the first source must be labelled.\nChrome:\n{chrome}" + ); + assert!( + chrome.contains("[grizzly ]\x1b[39m Compiling tantivy"), + "the second source must be labelled and aligned.\nChrome:\n{chrome}" + ); +} + +#[tokio::test] +async fn reports_skipped_optional_servers() { + let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); + + let mut joins = tokio::task::JoinSet::new(); + joins.spawn(async { Ok(Startup::Skipped(McpServerId::new("bookworm"))) }); + joins.spawn(async { Ok(Startup::Ready(McpServerId::new("grizzly"))) }); + let (startup, _lines) = startup_set(joins, vec![ + McpServerId::new("bookworm"), + McpServerId::new("grizzly"), + ]); + + let skipped = await_mcp_servers(startup, immediate_config(), Arc::new(printer)) + .await + .expect("an optional failure completes the wait"); + + assert_eq!(skipped, vec![McpServerId::new("bookworm")]); +} + +/// Drives the aggregate redraw: two servers start, one finishes while the other +/// is still pending, then the second finishes. +/// The line must go from both names, to the survivor alone, to cleared. +#[tokio::test(flavor = "multi_thread")] +async fn redraws_as_servers_finish() { + let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); + let printer = Arc::new(printer.with_terminal(TerminalCapability::interactive(Some(80)))); + + // Two independently-released tasks: releasing `bookworm` first makes + // `grizzly` the deterministic survivor of the mid-drain redraw. + let (bookworm_tx, bookworm_rx) = tokio::sync::oneshot::channel::<()>(); + let (grizzly_tx, grizzly_rx) = tokio::sync::oneshot::channel::<()>(); + let mut joins = tokio::task::JoinSet::new(); + joins.spawn(async move { + bookworm_rx.await.ok(); + Ok(Startup::Ready(McpServerId::new("bookworm"))) + }); + joins.spawn(async move { + grizzly_rx.await.ok(); + Ok(Startup::Ready(McpServerId::new("grizzly"))) + }); + let (startup, _lines) = startup_set(joins, vec![ + McpServerId::new("bookworm"), + McpServerId::new("grizzly"), + ]); + + let wait = tokio::spawn(await_mcp_servers( + startup, + immediate_config(), + printer.clone(), + )); + + // Advance on the rendered frames, not the clock: wait until each frame is + // actually in the buffer before releasing the next server, so a slow timer + // task can't make the release outrun the redraw it's supposed to observe. + wait_for_frame(&err, "2 MCP servers (bookworm, grizzly)").await; + bookworm_tx.send(()).expect("wait task is still running"); + wait_for_frame(&err, "MCP server grizzly…").await; + grizzly_tx.send(()).expect("wait task is still running"); + wait.await + .expect("task did not panic") + .expect("all startups succeed"); + printer.flush(); + + let chrome = err.lock(); + let both = chrome + .find("2 MCP servers (bookworm, grizzly)") + .expect("the aggregate two-server frame must render first"); + let survivor = chrome + .find("MCP server grizzly…") + .expect("the survivor-only frame must render after bookworm finishes"); + assert!( + both < survivor, + "the two-server frame must precede the survivor-only frame.\nChrome:\n{chrome}" + ); + assert!( + !chrome.contains("MCP server bookworm…"), + "bookworm was never the sole pending server; it must not render alone.\nChrome:\n{chrome}" + ); + assert!( + chrome.ends_with("\r\x1b[K"), + "finishing the wait must leave the line cleared.\nChrome:\n{chrome}" + ); +} + +#[test] +fn line_renders_full_when_it_fits() { + assert_eq!( + line(4.2, Some("MCP server bookworm"), Some(80)), + "⏱ Starting MCP server bookworm… 4.2s" + ); + // Unknown width leaves the line unbounded. + assert_eq!( + line(4.2, Some("MCP server bookworm"), None), + "⏱ Starting MCP server bookworm… 4.2s" + ); +} + +// A long server list forced to truncate must keep the elapsed-time suffix: the +// whole point of the line is the moving timer, so truncation has to fall on the +// server list, not the `Ns` tail. Testing the pure formatter at a fixed `secs` +// pins the invariant without depending on when the timer task first ticks. +#[test] +fn line_truncation_preserves_timer_suffix() { + let long = "MCP server bookworm-with-a-very-long-descriptive-server-name"; + let rendered = line(12.3, Some(long), Some(30)); + + assert!( + rendered.ends_with(" 12.3s"), + "suffix must survive: {rendered:?}" + ); + assert!( + rendered.contains('…'), + "server list must truncate: {rendered:?}" + ); + assert!( + display_width(&rendered) <= 30, + "must fit width: {rendered:?}" + ); +} + +// A terminal too narrow for even the prefix and suffix still keeps a moving +// timer rather than a static stub. +#[test] +fn line_ultra_narrow_keeps_bounded_timer() { + let rendered = line(7.0, Some("MCP server bookworm"), Some(6)); + + assert!( + display_width(&rendered) <= 6, + "must fit width: {rendered:?}" + ); + assert!( + rendered.contains("7.0s"), + "timer must survive: {rendered:?}" + ); +} diff --git a/crates/jp_cli/src/cmd/query/tool.rs b/crates/jp_cli/src/cmd/query/tool.rs index 83f63d1f9..1a35f56d2 100644 --- a/crates/jp_cli/src/cmd/query/tool.rs +++ b/crates/jp_cli/src/cmd/query/tool.rs @@ -7,11 +7,12 @@ pub(crate) mod builtins; pub(crate) mod coordinator; pub(crate) mod executor; pub(crate) mod inquiry; +pub(crate) mod mcp_executor; pub(crate) mod pending; pub(crate) mod prompter; pub(crate) use coordinator::{ToolCallDecision, ToolCallState, ToolCoordinator}; -pub(crate) use executor::TerminalExecutorSource; +pub(crate) use mcp_executor::TerminalExecutorSource; pub(crate) use pending::{PendingEntry, PendingTools, build_execution_plan}; pub(crate) use prompter::ToolPrompter; diff --git a/crates/jp_cli/src/cmd/query/tool/coordinator.rs b/crates/jp_cli/src/cmd/query/tool/coordinator.rs index 2e51af647..c0b336893 100644 --- a/crates/jp_cli/src/cmd/query/tool/coordinator.rs +++ b/crates/jp_cli/src/cmd/query/tool/coordinator.rs @@ -82,51 +82,39 @@ use std::{ sync::Arc, }; -use camino::{Utf8Path, Utf8PathBuf}; use indexmap::IndexMap; use inquire::error::InquireError; use jp_config::{ conversation::tool::{ - FormatMode, QuestionTarget, ResultMode, RunMode, ToolSource, ToolsConfig, - style::ParametersStyle, + QuestionTarget, ResultMode, RunMode, ToolsConfig, style::ParametersStyle, }, interrupt::ToolInterruptConfig, }; -use jp_conversation::{ - ConversationStream, - event::{ - CancellationReason, InquiryAnswerType, InquiryId, InquiryQuestion, InquiryRequest, - InquiryResponse, SelectOption, ToolCallRequest, ToolCallResponse, - }, -}; -use jp_editor::EditorBackend; -use jp_inquire::{ReplyEditMode, prompt::PromptBackend}; -use jp_llm::tool::{ - StderrSink, - executor::{Executor, ExecutorResult, ExecutorSource, PermissionInfo}, +use jp_conversation::event::{ + CancellationReason, InquiryAnswerType, InquiryId, InquiryQuestion, InquiryRequest, + InquiryResponse, InquirySource, SelectOption, ToolCallRequest, ToolCallResponse, }; -use jp_mcp::Client; -use jp_printer::Printer; +use jp_llm::query::ToolExecution; +use jp_mcp::server::StderrSink; use jp_tool::{AnswerType, Question}; use jp_workspace::ConversationMut; use serde_json::{Map, Value}; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use tracing::{debug, warn}; +use url::Url; use super::{ ToolRenderer, + executor::{Executor, ExecutorError, ExecutorResult, ExecutorSource, PermissionInfo, Review}, inquiry::{self, InquiryBackend, InquiryError}, prompter::{PermissionResult, ToolPrompter}, }; use crate::{ Error, cmd::query::{ - interrupt::signals::{ToolInterruptResult, handle_tool_interrupt}, - turn::{ - TurnCoordinator, - state::{PermissionCacheKey, ToolAnswerCacheKey, TurnState}, - }, + interrupt::signals::{InterruptUi, ToolInterruptResult, handle_tool_interrupt}, + turn::state::{PermissionCacheKey, ToolAnswerCacheKey, TurnState}, }, render::tool::RenderOutcome, signals::{InterruptNotice, SignalRouter}, @@ -172,19 +160,18 @@ enum ExecutionEvent { ResultModeProcessed { index: usize, - tool_id: String, - response: ToolCallResponse, + review: Review, }, } #[derive(Debug)] pub struct ExecutionResult { - /// Tool responses paired with the plan index supplied by the caller in - /// `executors`. + /// What the Host settled on per tool, paired with the plan index supplied + /// by the caller in `executors`. /// Indices may be sparse when the caller's plan also contains pre-resolved /// tools that bypass execution; merging those back into the original stream /// order is the caller's job. - pub responses: Vec<(usize, ToolCallResponse)>, + pub reviews: Vec<(usize, Review)>, /// How the execution phase ended, and what the caller should do next. pub outcome: ExecutionOutcome, @@ -275,6 +262,49 @@ struct ExecutingTool { stderr: Option, } +/// What an execution phase talks to, fixed from the moment it starts. +/// +/// Every handler in the phase needs some of these and none of them changes, so +/// they travel as one borrow rather than as eight repeated parameters. +struct PhaseServices<'a> { + /// Runs the inline prompts a question or a result review needs. + prompter: Arc, + + /// Answers a question routed to an assistant instead of the user. + inquiry_backend: Arc, + + /// Where a spawned prompt, inquiry, or execution reports back to. + event_tx: mpsc::Sender, + + /// Parent of every token this phase hands to a spawned task. + cancellation_token: CancellationToken, + + /// The conversation each inquiry pair is recorded on. + conv: &'a ConversationMut, + + /// Whether a user is there to answer a prompt at all. + interactive: bool, +} + +/// What an execution phase is keeping track of while its tools run. +/// +/// Indexed by the phase's own contiguous index, not the caller's plan index: +/// see [`ToolCoordinator::execute_with_prompting`] for why the two differ. +struct PhaseState { + /// Every call the phase started, kept for the life of the phase so an + /// answered question can re-spawn the tool it belongs to. + tools: HashMap, + + /// What the Host settled on per call, filled in as calls finish. + reviews: Vec>, + + /// Prompts waiting for the terminal, which one call holds at a time. + pending_prompts: VecDeque, + + /// Whether a prompt currently owns the terminal. + prompt_active: bool, +} + #[derive(Debug)] enum PendingPrompt { Question { @@ -291,6 +321,29 @@ enum PendingPrompt { }, } +/// A question one tool asked, and what routing it needs to know. +/// +/// The four travel together because answering needs all of them: the call to +/// resume, the name its configuration is keyed on, the question itself, and the +/// provenance the recorded `InquiryRequest` carries. +struct ToolQuestion { + tool_id: String, + tool_name: String, + question: Question, + source: InquirySource, +} + +/// What rendering a tool call before its approval prompt produced. +#[derive(Debug)] +enum PreRender { + /// Rendered; the content shown, when the style produced any. + Ready(Option), + + /// Held back until the call is admitted, because its formatter is a + /// user-configured command the execution service has not run yet. + Deferred, +} + /// Result of [`ToolCoordinator::decide_permission`] for a single tool. pub enum PermissionDecision { /// Tool can run immediately (unattended, persisted approval, non-TTY). @@ -390,6 +443,16 @@ pub struct ToolCoordinator { } impl ToolCoordinator { + /// The endpoint used by provider-owned tool dispatch. + pub fn endpoint(&self) -> Option { + self.executor_source.endpoint() + } + + /// Bind upcoming tool observations to the selected dispatch contract. + pub fn set_execution(&self, execution: ToolExecution) -> Result<(), ExecutorError> { + self.executor_source.set_execution(execution) + } + pub fn new(tools_config: ToolsConfig, executor_source: Box) -> Self { Self { executors: Vec::new(), @@ -428,6 +491,16 @@ impl ToolCoordinator { self.tool_states.insert(tool_id.into(), state); } + /// Remove an abandoned argument preview without changing executable calls. + pub(crate) fn discard_pending_tool(&mut self, tool_id: &str) { + if matches!( + self.tool_states.get(tool_id), + Some(ToolCallState::ReceivingArguments { .. }) + ) { + self.tool_states.remove(tool_id); + } + } + fn clear_tool_states(&mut self) { self.tool_states.clear(); } @@ -439,32 +512,6 @@ impl ToolCoordinator { .unwrap_or_default() } - /// Return the name the tool is invoked under. - /// - /// A tool's key in `conversation.tools` is the name the assistant calls. - /// Its `source` may name a differently named implementation - /// (`local.fs_list_files`, `mcp..`), and that is the name the - /// tool is actually invoked with. - /// Falls back to `tool_name` when the source names nothing. - pub fn invoked_name(&self, tool_name: &str) -> String { - self.tools_config - .get(tool_name) - .and_then(|config| match config.source() { - ToolSource::Builtin { tool } - | ToolSource::Local { tool } - | ToolSource::Mcp { tool, .. } => tool.clone(), - }) - .unwrap_or_else(|| tool_name.to_owned()) - } - - /// Return the format mode for a tool, falling back to `Ask` if the tool is - /// unknown (untrusted-by-default). - pub fn format_mode(&self, tool_name: &str) -> FormatMode { - self.tools_config - .get(tool_name) - .map_or(FormatMode::Ask, |c| c.format()) - } - /// Pre-render a tool call ahead of its approval prompt. /// /// Built-in parameter styles ([`ParametersStyle::Json`], @@ -473,47 +520,31 @@ impl ToolCoordinator { /// user needs to see the rendered call to make an informed approval /// decision. /// - /// [`ParametersStyle::Custom`] shells out to a user-configured command and - /// is gated by [`FormatMode`]: it only pre-renders when the tool opts in - /// via `format = "unattended"`; otherwise rendering is deferred until after - /// approval. - /// - /// Returns: + /// [`ParametersStyle::Custom`] renders whatever the execution service + /// produced. + /// A formatter configured with `format = "ask"` has not run yet at this + /// point, which is [`PreRender::Deferred`]. /// - /// - `Ok(Some(content))` if pre-render fired successfully — caller should - /// skip the post-approval render and use this content. - /// - `Ok(None)` if pre-render was suppressed (Custom style with `format = - /// "ask"`) — caller should follow the existing post-approval render - /// path. - /// - `Err(error_message)` if a custom formatter command failed — caller - /// should treat this as a tool failure and skip prompting. - pub(crate) async fn pre_render_for_prompt( + /// Returns `Err` if a formatter failed — the caller should treat that as a + /// tool failure and skip prompting. + fn pre_render_for_prompt( &self, - tool_name: &str, - arguments: &Map, + executor: &dyn Executor, tool_renderer: &ToolRenderer, - ) -> Result>, String> { - // `FormatMode::Ask` exists to defer side-effecting *custom* - // formatters until after approval — running a user-configured - // shell command before the user okays the tool would be - // surprising. Built-in styles are pure and have no side effects, - // so they always render before the prompt. - let should_pre_render = match self.parameter_style(tool_name) { - ParametersStyle::Custom(_) => { - matches!(self.format_mode(tool_name), FormatMode::Unattended) - } - ParametersStyle::Json | ParametersStyle::FunctionCall | ParametersStyle::Off => true, - }; - - if !should_pre_render { - return Ok(None); + ) -> Result { + let name = executor.tool_name(); + if matches!(self.parameter_style(name), ParametersStyle::Custom(_)) + && executor.formatted_arguments().is_none() + { + // Running a user-configured shell command before the user okays + // the tool would be surprising, so `format = "ask"` holds the + // formatter back until admission. Built-in styles are pure and + // have no side effects, so they always render before the prompt. + return Ok(PreRender::Deferred); } - match self - .render_approved_tool(tool_name, arguments, tool_renderer) - .await - { - RenderOutcome::Rendered { content } => Ok(Some(content)), + match self.render_executor(executor, tool_renderer) { + RenderOutcome::Rendered { content } => Ok(PreRender::Ready(content)), RenderOutcome::Suppressed { error } => Err(error), } } @@ -548,7 +579,7 @@ impl ToolCoordinator { /// 4. Return [`ToolCallDecision::Approved`], `Skipped`, or `Failed`. pub(crate) async fn resolve_tool_call_decision( &mut self, - executor: Box, + mut executor: Box, prompter: &ToolPrompter, interactive: bool, turn_state: &mut TurnState, @@ -561,13 +592,37 @@ impl ToolCoordinator { // it. prompter.set_background(tool_renderer.current_region()); + // Asking the service to format arguments for a call the user already + // said no to would run a formatter command for output nobody sees. + let remembered_denial = interactive + && executor.needs_permission() + && turn_state + .remembered_permission_decisions + .get(&PermissionCacheKey::new(executor.tool_name())) + == Some(&false); + let render_arguments = !self.is_hidden(executor.tool_name()) && !remembered_denial; + match executor.prepare(render_arguments).await { + Ok(Some(response)) => { + self.set_tool_state(&response.id, ToolCallState::Completed); + return ToolCallDecision::Skipped(response); + } + Ok(None) => {} + Err(error) => { + self.set_tool_state(executor.tool_id(), ToolCallState::Completed); + return ToolCallDecision::Failed(ToolCallResponse { + id: executor.tool_id().into(), + result: Err(error.to_string()), + }); + } + } + // Step 1: decide. let decision = self.decide_permission(executor, interactive, turn_state); // Step 2: handle prompt path. After this match, `executor` is // approved and `pre_rendered` is `Some(content)` if pre-rendering // already happened, `None` if a post-render is still needed. - let (executor, pre_rendered) = match decision { + let (mut executor, pre_rendered) = match decision { PermissionDecision::Approved(executor) => (executor, None), PermissionDecision::Skipped(response) => { return ToolCallDecision::Skipped(response); @@ -580,11 +635,9 @@ impl ToolCoordinator { // Built-in parameter styles always pre-render; Custom // formatters are gated on `format = "unattended"` // because they shell out to a user-controlled command. - let pre = match self - .pre_render_for_prompt(&info.tool_name, executor.arguments(), tool_renderer) - .await - { - Ok(maybe_content) => maybe_content, + let pre = match self.pre_render_for_prompt(executor.as_ref(), tool_renderer) { + Ok(PreRender::Ready(content)) => Some(content), + Ok(PreRender::Deferred) => None, Err(error) => { return ToolCallDecision::Failed(Self::render_failed_response( info.tool_id.clone(), @@ -616,16 +669,20 @@ impl ToolCoordinator { } }; + if let Err(error) = executor.approve().await { + self.set_tool_state(executor.tool_id(), ToolCallState::Completed); + return ToolCallDecision::Failed(ToolCallResponse { + id: executor.tool_id().into(), + result: Err(error.to_string()), + }); + } + // Step 3: render. If pre-rendered, use that; otherwise render now. let rendered_arguments = if let Some(pre) = pre_rendered { pre } else { let tool_name = executor.tool_name().to_owned(); - let args = executor.arguments().clone(); - match self - .render_approved_tool(&tool_name, &args, tool_renderer) - .await - { + match self.render_executor(executor.as_ref(), tool_renderer) { RenderOutcome::Rendered { content } => content, RenderOutcome::Suppressed { error } => { let id = executor.tool_id().to_owned(); @@ -644,6 +701,47 @@ impl ToolCoordinator { } } + /// Render one tool call's arguments for display. + /// + /// A `Custom` parameter style shows what the execution service's formatter + /// produced. + /// The formatter is a user-configured command, so it runs once, there, + /// under the call's access policy and cancellation token — never a second + /// time here. + fn render_executor(&self, executor: &dyn Executor, renderer: &ToolRenderer) -> RenderOutcome { + let name = executor.tool_name(); + if self.is_hidden(name) { + return RenderOutcome::Rendered { content: None }; + } + let ParametersStyle::Custom(_) = self.parameter_style(name) else { + return self.render_approved_tool(name, executor.arguments(), renderer); + }; + let Some(formatted) = executor.formatted_arguments() else { + // The service formats a call's arguments before releasing it, + // unless the call is hidden or configured not to run. Both of + // those are already handled, so no output here means there is no + // call to announce: a bare header would say otherwise. + return RenderOutcome::Rendered { content: None }; + }; + renderer.render_custom_result(name, formatted.clone().map_err(|error| error.to_string())) + } + + /// Acknowledge the execution service after the conversation owner flushes. + /// + /// Until this runs, each call is still parked on its final barrier and its + /// MCP response has not been returned to the caller. + /// Every call is acknowledged even when one fails, so one call's + /// disagreement does not strand the rest; the first failure is returned. + pub async fn acknowledge_reviews(&self, reviews: Vec) -> Result<(), ExecutorError> { + let mut failure = None; + for review in reviews { + if let Err(error) = self.executor_source.acknowledge(review).await { + failure.get_or_insert(error); + } + } + failure.map_or(Ok(()), Err) + } + pub fn question_target(&self, tool_name: &str, question_id: &str) -> Option { self.tools_config .get(tool_name) @@ -764,20 +862,18 @@ impl ToolCoordinator { /// Renders the tool call header and arguments after permission approval. /// - /// For non-Custom styles: prints the header with inline-formatted - /// arguments. - /// For Custom style: runs the custom formatter command, then prints header + /// Prints the header with inline-formatted arguments. + /// A hidden tool renders nothing and still returns `Rendered`, because it + /// also still executes. + /// + /// A `Custom` parameter style is rendered by [`render_executor`] from the + /// execution service's formatter output, not here. /// - /// - custom output atomically. - /// If the custom formatter fails, nothing is printed and - /// [`RenderOutcome::Suppressed`] is returned — the caller should abort - /// execution and return an error response to the LLM. - /// For hidden tools: renders nothing but returns `Rendered` (hidden tools - /// still execute). - pub(crate) async fn render_approved_tool( + /// [`render_executor`]: Self::render_executor + pub(crate) fn render_approved_tool( &self, tool_name: &str, - arguments: &serde_json::Map, + arguments: &Map, tool_renderer: &ToolRenderer, ) -> RenderOutcome { if self.is_hidden(tool_name) { @@ -785,9 +881,7 @@ impl ToolCoordinator { } let style = self.parameter_style(tool_name); - tool_renderer - .render_approved(tool_name, &self.invoked_name(tool_name), arguments, &style) - .await + tool_renderer.render_approved(tool_name, arguments, &style) } /// Determines permission for a single tool without blocking on user input. @@ -947,29 +1041,22 @@ impl ToolCoordinator { /// `interactive` gates every question and result prompt. /// The elapsed-time progress row takes no parameter: it is a status region, /// so the printer's own terminal capability decides whether it renders. - #[allow(clippy::too_many_arguments)] - #[allow(clippy::too_many_lines)] + #[expect(clippy::too_many_lines)] pub async fn execute_with_prompting( &mut self, executors: Vec<(usize, Box)>, prompter: Arc, signals: &SignalRouter, - turn_coordinator: &mut TurnCoordinator, turn_state: &mut TurnState, - printer: &Printer, - prompt_backend: &dyn PromptBackend, - editor: Option>, - edit_mode: ReplyEditMode, + interrupt_ui: &mut InterruptUi<'_>, inquiry_backend: Arc, conv: &ConversationMut, - mcp_client: &Client, - root: &Utf8Path, tool_renderer: &mut ToolRenderer, interactive: bool, ) -> ExecutionResult { if executors.is_empty() { return ExecutionResult { - responses: Vec::new(), + reviews: Vec::new(), outcome: ExecutionOutcome::Completed, }; } @@ -999,10 +1086,20 @@ impl ToolCoordinator { let total_tools = executors.len(); let cancellation_token = self.cancellation_token.clone(); let (event_tx, mut event_rx) = mpsc::channel::(32); - let mut executing_tools: HashMap = HashMap::new(); - let mut results: Vec> = vec![None; total_tools]; - let mut pending_prompts: VecDeque = VecDeque::new(); - let mut prompt_active = false; + let services = PhaseServices { + prompter, + inquiry_backend, + event_tx: event_tx.clone(), + cancellation_token: cancellation_token.clone(), + conv, + interactive, + }; + let mut state = PhaseState { + tools: HashMap::new(), + reviews: vec![None; total_tools], + pending_prompts: VecDeque::new(), + prompt_active: false, + }; // Claimed before the sinks below, not after: `StatusRegion::source` // copies the region it is asked of, so a sink taken from the inert @@ -1030,26 +1127,17 @@ impl ToolCoordinator { let stderr = stderr_sink(tool_renderer, &self.tools_config, &tool_name); - executing_tools.insert(index, ExecutingTool { - executor: Arc::clone(&executor), - tool_id: tool_id.clone(), - tool_name: tool_name.clone(), - accumulated_answers: accumulated_answers.clone(), - stderr: stderr.clone(), - }); - - self.set_tool_state(&tool_id, ToolCallState::Running); - - Self::spawn_tool_execution( - index, + let tool = ExecutingTool { executor, + tool_id: tool_id.clone(), + tool_name, accumulated_answers, - mcp_client.clone(), - root.to_path_buf(), - cancellation_token.child_token(), - event_tx.clone(), stderr, - ); + }; + + self.set_tool_state(&tool_id, ToolCallState::Running); + Self::spawn_tool_execution(index, &tool, &services); + state.tools.insert(index, tool); } // Forward interrupt notifications into the execution event channel. @@ -1076,27 +1164,16 @@ impl ToolCoordinator { while let Some(event) = event_rx.recv().await { match event { ExecutionEvent::ToolResult { index, result } => { - let Some(tool) = executing_tools.get_mut(&index) else { + if !state.tools.contains_key(&index) { warn!(index, "Received ToolResult for unknown tool."); continue; - }; - let response = &mut results[index]; + } self.handle_tool_result( result, - tool, index, - response, - &mut pending_prompts, - &mut prompt_active, - prompter.clone(), - &inquiry_backend, - conv, - mcp_client, - root, - &cancellation_token, - event_tx.clone(), + &mut state, + &services, turn_state, - interactive, tool_renderer, ); } @@ -1115,15 +1192,8 @@ impl ToolCoordinator { answer, persist_level, redact, - &mut executing_tools, - &mut pending_prompts, - &mut prompt_active, - prompter.clone(), - mcp_client, - root, - &cancellation_token, - event_tx.clone(), - conv, + &mut state, + &services, turn_state, ); } @@ -1138,38 +1208,29 @@ impl ToolCoordinator { // Close the recorded pair before the tool lookup, so an // unknown index cannot leave the request unpaired on // disk (sanitize() would drop it on the next load). - Self::record_inquiry_answer(conv, &inquiry_id, &answer); - if let Some(tool) = executing_tools.get_mut(&index) { + Self::record_inquiry_answer(services.conv, &inquiry_id, &answer); + if let Some(tool) = state.tools.get_mut(&index) { tool.accumulated_answers.insert(question_id, answer); self.set_tool_state(&tool.tool_id, ToolCallState::Running); - Self::spawn_tool_execution( - index, - tool.executor.clone(), - tool.accumulated_answers.clone(), - mcp_client.clone(), - root.to_path_buf(), - cancellation_token.child_token(), - event_tx.clone(), - tool.stderr.clone(), - ); + Self::spawn_tool_execution(index, tool, &services); } else { warn!(index, "Received InquiryResult for unknown tool."); } } Err(error) => { Self::record_inquiry_cancelled( - conv, + services.conv, &inquiry_id, Self::cancellation_reason(&error), ); - match executing_tools.get(&index) { + match state.tools.get(&index) { None => { warn!(index, %error, "Received InquiryResult for unknown tool."); } Some(tool) => { self.set_tool_state(&tool.tool_id, ToolCallState::Completed); - results[index] = Some(ToolCallResponse { + state.reviews[index] = Some(Review::replaced(ToolCallResponse { id: tool.tool_id.clone(), result: Err(format!( "The tool '{}' asked a follow-up question (\"{}\") that \ @@ -1179,7 +1240,7 @@ impl ToolCoordinator { the turn.", tool.tool_name, question_text, error, )), - }); + })); } } } @@ -1189,61 +1250,25 @@ impl ToolCoordinator { inquiry_id, reason, } => { - self.handle_prompt_cancelled( - index, - &inquiry_id, - reason, - &mut executing_tools, - &mut results, - &mut pending_prompts, - &mut prompt_active, - prompter.clone(), - event_tx.clone(), - conv, - ); + self.handle_prompt_cancelled(index, &inquiry_id, reason, &mut state, &services); } - ExecutionEvent::ResultModeProcessed { - index, - tool_id, - response, - } => { - prompt_active = false; - let tool_name = executing_tools - .get(&index) - .map(|t| t.tool_name.clone()) - .unwrap_or_default(); - let is_error = response.result.is_err(); - let (inline_results, results_file_link) = self - .tools_config - .get(&tool_name) - .map(|c| { - ( - c.style().inline_results(is_error).clone(), - c.style().results_file_link(is_error).clone(), - ) - }) - .unwrap_or_default(); - - let is_hidden = self - .tools_config - .get(&tool_name) - .is_some_and(|cfg| cfg.style().hidden); - if !is_hidden { - tool_renderer.render_result(&response, &inline_results, &results_file_link); + ExecutionEvent::ResultModeProcessed { index, review } => { + state.prompt_active = false; + // The tool is still registered: nothing removes an entry + // for the life of the phase, and this index came from one. + if let Some(tool) = state.tools.get(&index) { + let tool_name = tool.tool_name.clone(); + let tool_id = tool.tool_id.clone(); + self.render_result(&tool_name, &review.response, tool_renderer); + self.set_tool_state(&tool_id, ToolCallState::Completed); + } else { + warn!(index, "Received ResultModeProcessed for unknown tool."); } - - self.set_tool_state(&tool_id, ToolCallState::Completed); - results[index] = Some(response); - self.process_next_prompt( - &mut pending_prompts, - &mut prompt_active, - prompter.clone(), - &executing_tools, - event_tx.clone(), - ); + state.reviews[index] = Some(review); + self.process_next_prompt(&mut state, &services); } ExecutionEvent::Interrupt(notice) => { - if prompt_active { + if state.prompt_active { // An active inline prompt owns the terminal; pass the // interrupt down the handler stack instead of stacking // the menu on top of the prompt. @@ -1251,12 +1276,8 @@ impl ToolCoordinator { } else { let result = handle_tool_interrupt( &cancellation_token, - turn_coordinator, self.is_prompting(), - printer, - prompt_backend, - editor.clone(), - edit_mode, + interrupt_ui, &self.interrupt_config, ); @@ -1286,15 +1307,36 @@ impl ToolCoordinator { | ToolInterruptResult::PromptFailed | ToolInterruptResult::Declined => {} ToolInterruptResult::Restart => { + // Hold each call's service-side invocation open + // before cancelling the Host workers, so the + // re-preparation that follows continues the same + // logical calls instead of submitting new ones. + for tool in state.tools.values() { + tool.executor.pause_for_restart(); + } + cancellation_token.cancel(); outcome.upgrade(ExecutionOutcome::Restart); } ToolInterruptResult::Cancelled { response, exit } => { - cancelled_indices = results + cancelled_indices = state + .reviews .iter() .enumerate() .filter(|(_, r)| r.is_none()) .map(|(i, _)| i) .collect(); + // Hold each unfinished call open before + // cancelling the Host workers, so the + // cancellation response recorded below is what + // its MCP caller receives. An agent that owns + // the call builds its transcript from that, not + // from the conversation. + for index in &cancelled_indices { + if let Some(tool) = state.tools.get(index) { + tool.executor.hold_for_response(); + } + } + cancellation_token.cancel(); tools_cancelled = true; cancellation_message = response; if exit { @@ -1313,7 +1355,7 @@ impl ToolCoordinator { } } - if results.iter().all(Option::is_some) { + if state.reviews.iter().all(Option::is_some) { break; } } @@ -1324,37 +1366,43 @@ impl ToolCoordinator { tool_renderer.clear_progress(); - let mut responses: Vec<(usize, ToolCallResponse)> = plan_indices + let mut reviews: Vec<(usize, Review)> = plan_indices .into_iter() - .zip(results.into_iter().map(|r| { - r.unwrap_or_else(|| ToolCallResponse { - id: "unknown".to_string(), - result: Err("Tool did not complete".to_string()), + .zip(state.reviews.into_iter().map(|review| { + review.unwrap_or_else(|| { + Review::replaced(ToolCallResponse { + id: "unknown".to_owned(), + result: Err("Tool did not complete".to_owned()), + }) }) })) .collect(); if tools_cancelled { for &i in &cancelled_indices { - let Some((_, response)) = responses.get_mut(i) else { + let Some((_, review)) = reviews.get_mut(i) else { continue; }; - response.result = Ok(if let Some(msg) = &cancellation_message { + review.response.result = Ok(if let Some(msg) = &cancellation_message { format!("Tool run cancelled by user with a custom message:\n\n{msg}") } else { // No custom message: each cancelled tool answers with its // configured cancellation response. - let tool_name = executing_tools + let tool_name = state + .tools .get(&i) .map(|tool| tool.tool_name.as_str()) .unwrap_or_default(); self.cancellation_response(tool_name) }); + // The cancellation message stands in for whatever the tool + // would have produced. + review.edited = true; } } - ExecutionResult { responses, outcome } + ExecutionResult { reviews, outcome } } /// Builds an error response for a tool whose argument rendering failed. @@ -1374,20 +1422,18 @@ impl ToolCoordinator { } } - fn spawn_tool_execution( - index: usize, - executor: Arc, - answers: IndexMap, - client: Client, - root: Utf8PathBuf, - token: CancellationToken, - tx: mpsc::Sender, - stderr: Option, - ) { + /// Run one attempt of a tool, reporting back through the phase's channel. + /// + /// The answers are snapshotted here rather than borrowed, so the spawned + /// task is unaffected by a later question adding to them. + fn spawn_tool_execution(index: usize, tool: &ExecutingTool, services: &PhaseServices<'_>) { + let executor = tool.executor.clone(); + let answers = tool.accumulated_answers.clone(); + let stderr = tool.stderr.clone(); + let token = services.cancellation_token.child_token(); + let tx = services.event_tx.clone(); tokio::spawn(async move { - let result = executor - .execute(&answers, &client, &root, token, stderr) - .await; + let result = executor.execute(&answers, token, stderr).await; let _err = tx.send(ExecutionEvent::ToolResult { index, result }).await; }); } @@ -1475,11 +1521,13 @@ impl ToolCoordinator { id: String, tool_name: String, question: Question, - backend: Arc, - mut events: ConversationStream, - cancellation_token: CancellationToken, - event_tx: mpsc::Sender, + services: &PhaseServices<'_>, ) { + let backend = Arc::clone(&services.inquiry_backend); + let cancellation_token = services.cancellation_token.child_token(); + let event_tx = services.event_tx.clone(); + let mut events = services.conv.events().clone(); + // Insert a ToolCallResponse into the cloned stream so the LLM sees the // tool as "paused". The ID must match the original ToolCallRequest.id // so providers can resolve the tool name when converting events to @@ -1516,112 +1564,127 @@ impl ToolCoordinator { }); } - #[allow(clippy::too_many_arguments)] - #[allow(clippy::too_many_lines)] + /// Show a finished call's result, unless the tool renders no chrome. + fn render_result(&self, tool_name: &str, response: &ToolCallResponse, renderer: &ToolRenderer) { + if self.is_hidden(tool_name) { + return; + } + let style = self.tools_config.get(tool_name); + let is_error = response.result.is_err(); + let (inline_results, results_file_link) = style + .map(|config| { + ( + config.style().inline_results(is_error).clone(), + config.style().results_file_link(is_error).clone(), + ) + }) + .unwrap_or_default(); + renderer.render_result(response, &inline_results, &results_file_link); + } + + /// Show a call's result and record it as the content the Host settled on. + /// + /// This is the path for a result nobody was asked about: either the tool is + /// configured to deliver unattended, or there is no user to ask. + fn finish_tool_call( + &mut self, + tool: &ExecutingTool, + response: ToolCallResponse, + tracked_review: &mut Option, + tool_renderer: &ToolRenderer, + ) { + self.render_result(&tool.tool_name, &response, tool_renderer); + self.set_tool_state(&tool.tool_id, ToolCallState::Completed); + *tracked_review = Some(Review::unchanged(response)); + } + + /// Take one finished attempt and decide what the phase does about it. + /// + /// `index` names a call the phase started; the caller checks that before + /// dispatching here. fn handle_tool_result( &mut self, result: ExecutorResult, - tool: &mut ExecutingTool, index: usize, - tracked_response: &mut Option, - pending_prompts: &mut VecDeque, - prompt_active: &mut bool, - prompter: Arc, - inquiry_backend: &Arc, - conv: &ConversationMut, - mcp_client: &Client, - root: &Utf8Path, - cancellation_token: &CancellationToken, - event_tx: mpsc::Sender, + state: &mut PhaseState, + services: &PhaseServices<'_>, turn_state: &mut TurnState, - interactive: bool, tool_renderer: &ToolRenderer, ) { + let PhaseState { + tools, + reviews, + pending_prompts, + prompt_active, + } = state; + let Some(tool) = tools.get_mut(&index) else { + return; + }; + let tracked_review = &mut reviews[index]; match result { ExecutorResult::Completed(response) => { - let is_error = response.result.is_err(); - let (inline_results, results_file_link) = self - .tools_config - .get(&tool.tool_name) - .map(|c| { - ( - c.style().inline_results(is_error).clone(), - c.style().results_file_link(is_error).clone(), - ) - }) - .unwrap_or_default(); - match self.result_mode(&tool.tool_name) { ResultMode::Unattended => { - let is_hidden = self - .tools_config - .get(&tool.tool_name) - .is_some_and(|cfg| cfg.style().hidden); - if !is_hidden { - tool_renderer.render_result( - &response, - &inline_results, - &results_file_link, - ); - } - self.set_tool_state(&tool.tool_id, ToolCallState::Completed); - *tracked_response = Some(response); + self.finish_tool_call(tool, response, tracked_review, tool_renderer); } + // The execution service applies `result = "skip"` itself, + // so this response is already its skip message rather than + // the tool's output. Rendering it would announce a result + // the configuration asked not to deliver. ResultMode::Skip => { self.set_tool_state(&tool.tool_id, ToolCallState::Completed); - *tracked_response = Some(ToolCallResponse { - id: response.id, - result: Ok("Result delivery skipped by configuration.".to_string()), - }); + *tracked_review = Some(Review::unchanged(response)); } - result_mode @ (ResultMode::Ask | ResultMode::Edit) => { - // Both Ask and Edit prompt whenever a user is there to - // answer: the Edit flow uses the inline widget, which - // does not need a configured editor. - let can_prompt = interactive; - if can_prompt { - if *prompt_active { - pending_prompts.push_back(PendingPrompt::ResultMode { - index, - tool_id: tool.tool_id.clone(), - tool_name: tool.tool_name.clone(), - response, - result_mode, - }); - } else { - *prompt_active = true; - self.set_tool_state( - &tool.tool_id, - ToolCallState::AwaitingResultEdit, - ); - Self::spawn_result_mode_prompt( - index, - tool.tool_id.clone(), - tool.tool_name.clone(), - response, - result_mode, - prompter, - event_tx, - ); - } + // Nobody is there to answer, so the configured prompt is + // skipped and the result stands as the tool produced it. + ResultMode::Ask | ResultMode::Edit if !services.interactive => { + self.finish_tool_call(tool, response, tracked_review, tool_renderer); + } + // Both Ask and Edit prompt whenever a user is there to + // answer: the Edit flow uses the inline widget, which does + // not need a configured editor. + result_mode => { + if *prompt_active { + pending_prompts.push_back(PendingPrompt::ResultMode { + index, + tool_id: tool.tool_id.clone(), + tool_name: tool.tool_name.clone(), + response, + result_mode, + }); } else { - let is_hidden = self - .tools_config - .get(&tool.tool_name) - .is_some_and(|cfg| cfg.style().hidden); - if !is_hidden { - tool_renderer.render_result( - &response, - &inline_results, - &results_file_link, - ); - } - self.set_tool_state(&tool.tool_id, ToolCallState::Completed); - *tracked_response = Some(response); + *prompt_active = true; + self.set_tool_state(&tool.tool_id, ToolCallState::AwaitingResultEdit); + Self::spawn_result_mode_prompt( + index, + tool.tool_name.clone(), + response, + result_mode, + services, + ); } } } } + ExecutorResult::Failed(error) => { + // Nothing ran, and the reason is JP's rather than the tool's. + // The user gets the detail; the model gets only the fact that + // the call did not happen, so it can decide to retry. + warn!( + %error, + tool = %tool.tool_name, + "Tool call could not be completed." + ); + self.set_tool_state(&tool.tool_id, ToolCallState::Completed); + *tracked_review = Some(Review::replaced(ToolCallResponse { + id: tool.tool_id.clone(), + result: Err(format!( + "Tool '{}' was not executed: JP could not complete the call. You may \ + retry it.", + tool.tool_name + )), + })); + } ExecutorResult::NeedsInput { tool_id, tool_name, @@ -1629,160 +1692,170 @@ impl ToolCoordinator { source, accumulated_answers, } => { - tool.accumulated_answers = accumulated_answers.clone(); - - // Allocate the inquiry ID (incrementing the per-turn attempt - // counter) and record the `InquiryRequest` before any routing - // decision, so every question round-trip lands on the stream - // regardless of how it is answered. - let attempt = turn_state.next_inquiry_attempt(&tool_id, question.id.as_str()); - let inquiry_id = InquiryId::new(inquiry::tool_call_inquiry_id( - &tool_id, - question.id.as_str(), - attempt, - )); - let inquiry_question = tool_question_to_inquiry_question(&question); - conv.update_events(|events| { - events - .current_turn_mut() - .add_inquiry_request(InquiryRequest::new( - inquiry_id.clone(), - source, - inquiry_question, - )) - .build() - .expect("Invalid ConversationStream state"); - }); + tool.accumulated_answers = accumulated_answers; + self.route_tool_question( + ToolQuestion { + tool_id, + tool_name, + question, + source, + }, + index, + tool, + tracked_review, + pending_prompts, + prompt_active, + services, + turn_state, + ); + } + } + } - let is_secret = question.answer_type == AnswerType::Secret; - - // Secrets never enter or read the turn-answer cache. - if !is_secret { - let answer_key = ToolAnswerCacheKey::new(&tool_name, question.id.as_str()); - let persisted_answer = - turn_state.remembered_tool_answers.get(&answer_key).cloned(); - if let Some(answer) = persisted_answer { - Self::record_inquiry_answer(conv, &inquiry_id, &answer); - tool.accumulated_answers - .insert(question.id.to_string(), answer); - Self::spawn_tool_execution( - index, - tool.executor.clone(), - tool.accumulated_answers.clone(), - mcp_client.clone(), - root.to_path_buf(), - cancellation_token.clone(), - event_tx, - tool.stderr.clone(), - ); - return; - } - } + /// Decide who answers a tool's question, and set that in motion. + /// + /// The `InquiryRequest` is recorded before any routing decision, so every + /// question round-trip lands on the stream however it is answered. + /// A question answered from the turn cache or from configuration resumes + /// the tool here; anything else hands off to a prompt or to the assistant + /// and resumes on a later event. + #[expect(clippy::too_many_lines)] + fn route_tool_question( + &mut self, + question: ToolQuestion, + index: usize, + tool: &mut ExecutingTool, + tracked_review: &mut Option, + pending_prompts: &mut VecDeque, + prompt_active: &mut bool, + services: &PhaseServices<'_>, + turn_state: &mut TurnState, + ) { + let conv = services.conv; + let ToolQuestion { + tool_id, + tool_name, + question, + source, + } = question; + // Allocate the inquiry ID, incrementing the per-turn attempt counter. + let attempt = turn_state.next_inquiry_attempt(&tool_id, question.id.as_str()); + let inquiry_id = InquiryId::new(inquiry::tool_call_inquiry_id( + &tool_id, + question.id.as_str(), + attempt, + )); + let inquiry_question = tool_question_to_inquiry_question(&question); + conv.update_events(|events| { + events + .current_turn_mut() + .add_inquiry_request(InquiryRequest::new( + inquiry_id.clone(), + source, + inquiry_question, + )) + .build() + .expect("Invalid ConversationStream state"); + }); - if let Some(answer) = self.static_answer(&tool_name, question.id.as_str()) { - // The tool still receives the configured value in-memory; - // only the persisted record is redacted for secrets. - if is_secret { - Self::record_inquiry_redacted(conv, &inquiry_id); - } else { - Self::record_inquiry_answer(conv, &inquiry_id, &answer); - } - tool.accumulated_answers - .insert(question.id.to_string(), answer); - Self::spawn_tool_execution( - index, - tool.executor.clone(), - tool.accumulated_answers.clone(), - mcp_client.clone(), - root.to_path_buf(), - cancellation_token.clone(), - event_tx, - tool.stderr.clone(), - ); - return; - } + let is_secret = question.answer_type == AnswerType::Secret; + + // Secrets never enter or read the turn-answer cache. + if !is_secret { + let answer_key = ToolAnswerCacheKey::new(&tool_name, question.id.as_str()); + let persisted_answer = turn_state.remembered_tool_answers.get(&answer_key).cloned(); + if let Some(answer) = persisted_answer { + Self::record_inquiry_answer(conv, &inquiry_id, &answer); + tool.accumulated_answers + .insert(question.id.to_string(), answer); + Self::spawn_tool_execution(index, tool, services); + return; + } + } - let target = self - .question_target(&tool_name, question.id.as_str()) - .unwrap_or(QuestionTarget::User); - - tracing::info!( - tool_name = %tool_name, - tool_id = %tool_id, - question_id = %question.id, - question_text = %question.text, - question_type = ?question.answer_type, - target = ?target, - interactive = interactive, - "Tool question received, routing to target", - ); + if let Some(answer) = self.static_answer(&tool_name, question.id.as_str()) { + // The tool still receives the configured value in-memory; + // only the persisted record is redacted for secrets. + if is_secret { + Self::record_inquiry_redacted(conv, &inquiry_id); + } else { + Self::record_inquiry_answer(conv, &inquiry_id, &answer); + } + tool.accumulated_answers + .insert(question.id.to_string(), answer); + Self::spawn_tool_execution(index, tool, services); + return; + } - if interactive && target.is_user() { - if *prompt_active { - pending_prompts.push_back(PendingPrompt::Question { - index, - question, - inquiry_id, - }); - } else { - *prompt_active = true; - self.set_tool_state(&tool_id, ToolCallState::AwaitingInput); - Self::spawn_user_prompt( - index, - question, - inquiry_id, - prompter.clone(), - event_tx, - ); - } - } else if is_secret { - // A secret requires a human at an interactive prompt; it - // must never route to the inquiry backend. Fail the tool - // and close the recorded inquiry with the guard's reason. - let (reason, message) = if target.is_user() { - ( - CancellationReason::NoPromptBackend, - format!( - "The tool '{tool_name}' asked for a secret value, which requires \ - an interactive prompt, but no interactive terminal is available." - ), - ) - } else { - ( - CancellationReason::AssistantRoutingDenied, - format!( - "The tool '{tool_name}' asked for a secret value, which must be \ - entered by a human and cannot be routed to the assistant." - ), - ) - }; - Self::record_inquiry_cancelled(conv, &inquiry_id, reason); - self.set_tool_state(&tool_id, ToolCallState::Completed); - *tracked_response = Some(ToolCallResponse { - id: tool_id.clone(), - result: Err(message), - }); - } else { - // The `InquiryRequest` is already recorded above; spawn the - // async inquiry on a cloned snapshot. - Self::spawn_inquiry( - index, - inquiry_id, - tool_id.clone(), - tool_name, - question, - Arc::clone(inquiry_backend), - conv.events().clone(), - cancellation_token.child_token(), - event_tx.clone(), - ); - self.set_tool_state(&tool_id, ToolCallState::AwaitingInput); - } + let target = self + .question_target(&tool_name, question.id.as_str()) + .unwrap_or(QuestionTarget::User); + + tracing::info!( + tool_name = %tool_name, + tool_id = %tool_id, + question_id = %question.id, + question_text = %question.text, + question_type = ?question.answer_type, + target = ?target, + interactive = services.interactive, + "Tool question received, routing to target", + ); + + if services.interactive && target.is_user() { + if *prompt_active { + pending_prompts.push_back(PendingPrompt::Question { + index, + question, + inquiry_id, + }); + } else { + *prompt_active = true; + self.set_tool_state(&tool_id, ToolCallState::AwaitingInput); + Self::spawn_user_prompt(index, question, inquiry_id, services); } + } else if is_secret { + // A secret requires a human at an interactive prompt; it must never + // route to the inquiry backend. Fail the tool and close the + // recorded inquiry with the guard's reason. + let (reason, message) = if target.is_user() { + ( + CancellationReason::NoPromptBackend, + format!( + "The tool '{tool_name}' asked for a secret value, which requires an \ + interactive prompt, but no interactive terminal is available." + ), + ) + } else { + ( + CancellationReason::AssistantRoutingDenied, + format!( + "The tool '{tool_name}' asked for a secret value, which must be entered \ + by a human and cannot be routed to the assistant." + ), + ) + }; + Self::record_inquiry_cancelled(conv, &inquiry_id, reason); + self.set_tool_state(&tool_id, ToolCallState::Completed); + *tracked_review = Some(Review::replaced(ToolCallResponse { + id: tool_id.clone(), + result: Err(message), + })); + } else { + // The `InquiryRequest` is already recorded above; spawn the + // async inquiry on a cloned snapshot. + Self::spawn_inquiry( + index, + inquiry_id, + tool_id.clone(), + tool_name, + question, + services, + ); + self.set_tool_state(&tool_id, ToolCallState::AwaitingInput); } } - #[allow(clippy::too_many_arguments)] fn handle_prompt_answer( &mut self, index: usize, @@ -1791,28 +1864,21 @@ impl ToolCoordinator { answer: Value, persist_level: jp_tool::PersistLevel, redact: bool, - executing_tools: &mut HashMap, - pending_prompts: &mut VecDeque, - prompt_active: &mut bool, - prompter: Arc, - mcp_client: &Client, - root: &Utf8Path, - cancellation_token: &CancellationToken, - event_tx: mpsc::Sender, - conv: &ConversationMut, + state: &mut PhaseState, + services: &PhaseServices<'_>, turn_state: &mut TurnState, ) { - *prompt_active = false; + state.prompt_active = false; // Close the recorded inquiry with the user's answer; a secret answer // is persisted as `Redacted` and never carries the value. if redact { - Self::record_inquiry_redacted(conv, inquiry_id); + Self::record_inquiry_redacted(services.conv, inquiry_id); } else { - Self::record_inquiry_answer(conv, inquiry_id, &answer); + Self::record_inquiry_answer(services.conv, inquiry_id, &answer); } - if let Some(tool) = executing_tools.get_mut(&index) { + if let Some(tool) = state.tools.get_mut(&index) { // Secrets never enter the turn-answer cache. if persist_level == jp_tool::PersistLevel::Turn && !redact { let answer_key = ToolAnswerCacheKey::new(&tool.tool_name, &question_id); @@ -1822,73 +1888,47 @@ impl ToolCoordinator { } tool.accumulated_answers.insert(question_id, answer); self.set_tool_state(&tool.tool_id, ToolCallState::Running); - Self::spawn_tool_execution( - index, - tool.executor.clone(), - tool.accumulated_answers.clone(), - mcp_client.clone(), - root.to_path_buf(), - cancellation_token.clone(), - event_tx.clone(), - tool.stderr.clone(), - ); + Self::spawn_tool_execution(index, tool, services); } - self.process_next_prompt( - pending_prompts, - prompt_active, - prompter, - executing_tools, - event_tx, - ); + self.process_next_prompt(state, services); } - #[allow(clippy::too_many_arguments)] fn handle_prompt_cancelled( &mut self, index: usize, inquiry_id: &InquiryId, reason: CancellationReason, - executing_tools: &mut HashMap, - results: &mut [Option], - pending_prompts: &mut VecDeque, - prompt_active: &mut bool, - prompter: Arc, - event_tx: mpsc::Sender, - conv: &ConversationMut, + state: &mut PhaseState, + services: &PhaseServices<'_>, ) { - *prompt_active = false; + state.prompt_active = false; // A user cancellation (Esc / Ctrl-C / EOF at the prompt) completes the // tool benignly; a prompt failure is a tool-level error. let result = match reason { - CancellationReason::User => Ok("Tool input cancelled by user.".to_string()), - _ => Err("Tool input prompt failed.".to_string()), + CancellationReason::User => Ok("Tool input cancelled by user.".to_owned()), + _ => Err("Tool input prompt failed.".to_owned()), }; - Self::record_inquiry_cancelled(conv, inquiry_id, reason); + Self::record_inquiry_cancelled(services.conv, inquiry_id, reason); - if let Some(tool) = executing_tools.get(&index) { + if let Some(tool) = state.tools.get(&index) { self.set_tool_state(&tool.tool_id, ToolCallState::Completed); - results[index] = Some(ToolCallResponse { + state.reviews[index] = Some(Review::replaced(ToolCallResponse { id: tool.tool_id.clone(), result, - }); + })); } - self.process_next_prompt( - pending_prompts, - prompt_active, - prompter, - executing_tools, - event_tx, - ); + self.process_next_prompt(state, services); } fn spawn_user_prompt( index: usize, question: Question, inquiry_id: InquiryId, - prompter: Arc, - event_tx: mpsc::Sender, + services: &PhaseServices<'_>, ) { + let prompter = services.prompter.clone(); + let event_tx = services.event_tx.clone(); let question_id = question.id.to_string(); let redact = question.answer_type == AnswerType::Secret; tokio::task::spawn_blocking(move || match prompter.prompt_question(&question) { @@ -1919,80 +1959,76 @@ impl ToolCoordinator { }); } - #[allow(clippy::too_many_arguments)] fn spawn_result_mode_prompt( index: usize, - tool_id: String, tool_name: String, response: ToolCallResponse, result_mode: ResultMode, - prompter: Arc, - event_tx: mpsc::Sender, + services: &PhaseServices<'_>, ) { + let prompter = services.prompter.clone(); + let event_tx = services.event_tx.clone(); tokio::task::spawn_blocking(move || { - let final_response = match result_mode { + // Whether the content changed is decided here, where both the + // offered response and the user's answer are in hand. Downstream + // it becomes `Review::edited`, which is what lets the execution + // service hand an unedited result back to the caller intact + // instead of re-deriving it from the recorded text. + let review = match result_mode { ResultMode::Ask => match prompter.prompt_result_confirmation(&tool_name) { - Ok(true) => response, - Ok(false) => ToolCallResponse { + Ok(true) => Review::unchanged(response), + Ok(false) => Review::replaced(ToolCallResponse { id: response.id, - result: Ok("Result delivery skipped by user.".to_string()), - }, - Err(e) if e.to_string().contains("edit_requested") => { + result: Ok("Result delivery skipped by user.".to_owned()), + }), + Err(error) if error.to_string().contains("edit_requested") => { Self::handle_edit_result(&prompter, response) } - Err(_) => ToolCallResponse { + Err(_) => Review::replaced(ToolCallResponse { id: response.id, - result: Ok("Result delivery cancelled.".to_string()), - }, + result: Ok("Result delivery cancelled.".to_owned()), + }), }, ResultMode::Edit => Self::handle_edit_result(&prompter, response), - _ => response, + _ => Review::unchanged(response), }; - drop(event_tx.blocking_send(ExecutionEvent::ResultModeProcessed { - index, - tool_id, - response: final_response, - })); + drop(event_tx.blocking_send(ExecutionEvent::ResultModeProcessed { index, review })); }); } - fn handle_edit_result(prompter: &ToolPrompter, response: ToolCallResponse) -> ToolCallResponse { - let result_str = response.result.as_ref().map_or("", |s| s.as_str()); - match prompter.edit_result(result_str) { - Ok(Some(edited)) => ToolCallResponse { + fn handle_edit_result(prompter: &ToolPrompter, response: ToolCallResponse) -> Review { + let original = response.result.as_deref().unwrap_or_default(); + match prompter.edit_result(original) { + Ok(Some(edited)) => Review::replaced(ToolCallResponse { id: response.id, result: Ok(edited), - }, - Ok(None) => response, - Err(_) => ToolCallResponse { + }), + // The editor closed without a change, so the tool's own result + // stands. + Ok(None) => Review::unchanged(response), + Err(_) => Review::replaced(ToolCallResponse { id: response.id, - result: Ok("Result edit cancelled.".to_string()), - }, + result: Ok("Result edit cancelled.".to_owned()), + }), } } - fn process_next_prompt( - &mut self, - pending_prompts: &mut VecDeque, - prompt_active: &mut bool, - prompter: Arc, - executing_tools: &HashMap, - event_tx: mpsc::Sender, - ) { - let Some(next) = pending_prompts.pop_front() else { + /// Hand the terminal to the next waiting prompt, if there is one. + fn process_next_prompt(&mut self, state: &mut PhaseState, services: &PhaseServices<'_>) { + let Some(next) = state.pending_prompts.pop_front() else { return; }; - *prompt_active = true; + state.prompt_active = true; match next { PendingPrompt::Question { index, question, inquiry_id, } => { - if let Some(tool) = executing_tools.get(&index) { + if let Some(tool) = state.tools.get(&index) { self.set_tool_state(&tool.tool_id, ToolCallState::AwaitingInput); } - Self::spawn_user_prompt(index, question, inquiry_id, prompter, event_tx); + Self::spawn_user_prompt(index, question, inquiry_id, services); } PendingPrompt::ResultMode { index, @@ -2002,15 +2038,7 @@ impl ToolCoordinator { result_mode, } => { self.set_tool_state(&tool_id, ToolCallState::AwaitingResultEdit); - Self::spawn_result_mode_prompt( - index, - tool_id, - tool_name, - response, - result_mode, - prompter.clone(), - event_tx.clone(), - ); + Self::spawn_result_mode_prompt(index, tool_name, response, result_mode, services); } } } diff --git a/crates/jp_cli/src/cmd/query/tool/coordinator_tests.rs b/crates/jp_cli/src/cmd/query/tool/coordinator_tests.rs index 0cb1b65ce..2f79c1e91 100644 --- a/crates/jp_cli/src/cmd/query/tool/coordinator_tests.rs +++ b/crates/jp_cli/src/cmd/query/tool/coordinator_tests.rs @@ -1,21 +1,37 @@ use async_trait::async_trait; +#[cfg(unix)] use camino_tempfile::Utf8TempDir; +#[cfg(unix)] +use jp_config::AppConfig; use jp_config::conversation::tool::{ToolConfig, ToolSource, style::PartialDisplayStyleConfig}; +#[cfg(unix)] +use jp_inquire::ReplyEditMode; use jp_inquire::{ReplyOutcome, prompt::MockPromptBackend}; -use jp_llm::tool::executor::MockExecutor; +#[cfg(unix)] +use jp_mcp::{Client, server::builtin::BuiltinExecutors}; use jp_printer::{ErrChannel, OutputFormat, Printer}; +#[cfg(unix)] +use jp_tool::{InvocationContext, ToolDefinition, ToolDocs}; use schematic::Config as _; +#[cfg(unix)] +use serde_json::json; + +use super::*; +#[cfg(unix)] +use crate::{ + access::approvals::ApprovalStore, cmd::query::tool::mcp_executor::TerminalExecutorSource, +}; +use crate::{ + cmd::query::tool::executor::mock::{MockExecutor, TestExecutorSource}, + render::tool::ToolRenderer, +}; + +fn empty_executor_source() -> Box { + Box::new(TestExecutorSource::new()) +} -use super::{super::executor::TerminalExecutorSource, *}; -use crate::render::tool::ToolRenderer; - -fn empty_executor_source() -> Box { - Box::new(TerminalExecutorSource::new( - jp_llm::tool::builtin::BuiltinExecutors::new(), - &[], - std::sync::Arc::new(crate::access::approvals::ApprovalStore::default()), - jp_llm::tool::InvocationContext::default(), - )) +fn strip_ansi(text: &str) -> String { + String::from_utf8(strip_ansi_escapes::strip(text)).expect("valid utf-8 after stripping ANSI") } #[test] @@ -278,18 +294,13 @@ fn test_static_answer_with_configured_answer() { ); } -#[tokio::test] -async fn test_pre_render_for_prompt_function_call_fires_before_approval() { - // Regression test for the bug where `fs_delete_file`-style tools - // (built-in parameter style + `run = "ask"`) showed the permission - // prompt without first rendering the arguments. `FormatMode::Ask` - // exists to defer side-effecting custom formatters; it should not - // suppress rendering for the pure built-in styles. +/// Build a coordinator around a single tool with the given parameter style. +fn coordinator_with_style(name: &str, parameters: ParametersStyle) -> ToolCoordinator { let tool_config = ToolConfig::from_partial( jp_config::conversation::tool::PartialToolConfig { source: Some(ToolSource::Builtin { tool: None }), style: Some(PartialDisplayStyleConfig { - parameters: Some(ParametersStyle::FunctionCall), + parameters: Some(parameters), ..Default::default() }), ..Default::default() @@ -299,106 +310,87 @@ async fn test_pre_render_for_prompt_function_call_fires_before_approval() { .expect("valid tool config"); let mut tools_config = jp_config::AppConfig::new_test().conversation.tools; - tools_config.insert("fs_delete_file".to_string(), tool_config); - - let coordinator = ToolCoordinator::new(tools_config, empty_executor_source()); + tools_config.insert(name.to_owned(), tool_config); + ToolCoordinator::new(tools_config, empty_executor_source()) +} - // Sanity-check the precondition: with no explicit `format` and the - // default `run = "ask"`, the format mode derives to `Ask`. The bug - // was that this gated rendering even for non-Custom styles. - assert_eq!(coordinator.format_mode("fs_delete_file"), FormatMode::Ask); +#[test] +fn test_pre_render_for_prompt_function_call_fires_before_approval() { + // Regression test for the bug where `fs_delete_file`-style tools + // (built-in parameter style + `run = "ask"`) showed the permission prompt + // without first rendering the arguments. Deferral exists to hold back a + // side-effecting custom formatter, and must not suppress rendering for the + // pure built-in styles. + let coordinator = coordinator_with_style("fs_delete_file", ParametersStyle::FunctionCall); let (printer, _stdout, stderr) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let style_config = jp_config::AppConfig::new_test().style; - let root = Utf8TempDir::new().expect("temp dir"); let tool_renderer = ToolRenderer::new( ErrChannel::new(printer.clone()), - style_config, - root.path().to_owned(), - jp_llm::tool::InvocationContext::default(), + jp_config::AppConfig::new_test().style, ); let mut args = Map::new(); args.insert("path".into(), Value::String("src/foo.rs".into())); + let executor = MockExecutor::completed("call-1", "fs_delete_file", "done") + .with_arguments(args) + .with_permission_info(PermissionInfo { + tool_id: "call-1".into(), + tool_name: "fs_delete_file".into(), + tool_source: ToolSource::Builtin { tool: None }, + run_mode: RunMode::Ask, + arguments: Value::Object(Map::new()), + }); - let result = coordinator - .pre_render_for_prompt("fs_delete_file", &args, &tool_renderer) - .await; + let result = coordinator.pre_render_for_prompt(&executor, &tool_renderer); - // Non-Custom styles should always pre-render. `content` is `None` - // because only Custom formatters produce persistable rendered content. + // Built-in styles print their arguments inline, so they render before the + // prompt and produce no content for the caller to persist. assert!( - matches!(result, Ok(Some(None))), + matches!(result, Ok(PreRender::Ready(None))), "pre-render should fire for FunctionCall style, got: {result:?}" ); printer.flush(); - let output = stderr.lock(); - assert!( - output.contains("fs_delete_file"), - "stderr should contain tool name; got: {output:?}" - ); - assert!( - output.contains("src/foo.rs"), - "stderr should contain the rendered argument; got: {output:?}" + assert_eq!( + strip_ansi(&stderr.lock()), + "Calling tool fs_delete_file(path: \"src/foo.rs\")\n" ); } -#[tokio::test] -async fn test_pre_render_for_prompt_custom_ask_defers_rendering() { - // Counterpart to the test above: Custom formatters with the default - // `FormatMode::Ask` should still defer rendering until after approval, - // because the formatter is a user-controlled shell command. +#[test] +fn test_pre_render_for_prompt_custom_defers_until_the_service_formats() { + // Counterpart to the test above: a Custom formatter is a user-controlled + // command run by the execution service, so until the service reports its + // output there is nothing to show and rendering defers. use jp_config::conversation::tool::CommandConfigOrString; - let tool_config = ToolConfig::from_partial( - jp_config::conversation::tool::PartialToolConfig { - source: Some(ToolSource::Builtin { tool: None }), - style: Some(PartialDisplayStyleConfig { - parameters: Some(ParametersStyle::Custom(CommandConfigOrString::String( - "echo SHOULD-NOT-RUN".into(), - ))), - ..Default::default() - }), - ..Default::default() - }, - vec![], - ) - .expect("valid tool config"); - - let mut tools_config = jp_config::AppConfig::new_test().conversation.tools; - tools_config.insert("custom_tool".to_string(), tool_config); - - let coordinator = ToolCoordinator::new(tools_config, empty_executor_source()); - assert_eq!(coordinator.format_mode("custom_tool"), FormatMode::Ask); + let coordinator = coordinator_with_style( + "custom_tool", + ParametersStyle::Custom(CommandConfigOrString::String("echo SHOULD-NOT-RUN".into())), + ); let (printer, _stdout, stderr) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let style_config = jp_config::AppConfig::new_test().style; - let root = Utf8TempDir::new().expect("temp dir"); let tool_renderer = ToolRenderer::new( ErrChannel::new(printer.clone()), - style_config, - root.path().to_owned(), - jp_llm::tool::InvocationContext::default(), + jp_config::AppConfig::new_test().style, ); - let result = coordinator - .pre_render_for_prompt("custom_tool", &Map::new(), &tool_renderer) - .await; + // A mock executor never formats arguments, standing in for a service that + // has not run the formatter yet. + let executor = MockExecutor::completed("call-1", "custom_tool", "done"); + let result = coordinator.pre_render_for_prompt(&executor, &tool_renderer); assert!( - matches!(result, Ok(None)), - "Custom + format=ask should defer rendering, got: {result:?}" + matches!(result, Ok(PreRender::Deferred)), + "an unformatted Custom style should defer rendering, got: {result:?}" ); printer.flush(); - let output = stderr.lock(); - assert!( - !output.contains("SHOULD-NOT-RUN"), - "custom formatter must not have run; got: {output:?}" - ); + // Nothing at all is printed: not the formatter's output, and not a header + // with nothing under it. + assert_eq!(strip_ansi(&stderr.lock()), ""); } /// Minimal `Executor` whose `set_arguments` actually mutates state. @@ -435,10 +427,8 @@ impl Executor for EditableExecutor { async fn execute( &self, _answers: &IndexMap, - _mcp_client: &jp_mcp::Client, - _root: &camino::Utf8Path, _cancellation_token: tokio_util::sync::CancellationToken, - _stderr: Option, + _stderr: Option, ) -> ExecutorResult { unreachable!("resolve_tool_call_decision does not invoke execute()") } @@ -475,13 +465,7 @@ async fn test_resolve_tool_call_decision_invalidates_prerender_on_edit() { let (printer, _stdout, stderr) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); let style_config = jp_config::AppConfig::new_test().style; - let root = Utf8TempDir::new().expect("temp dir"); - let tool_renderer = ToolRenderer::new( - ErrChannel::new(printer.clone()), - style_config, - root.path().to_owned(), - jp_llm::tool::InvocationContext::default(), - ); + let tool_renderer = ToolRenderer::new(ErrChannel::new(printer.clone()), style_config); let mut pre_edit_args = Map::new(); pre_edit_args.insert("path".into(), Value::String("src/foo.rs".into())); @@ -794,12 +778,11 @@ fn test_pending_prompt_mixed_types_interleaved() { assert!(matches!(queue[2], PendingPrompt::Question { .. })); } -#[tokio::test] -async fn custom_formatter_receives_the_invoked_tool_name() { +#[test] +fn a_custom_style_shows_the_key_the_assistant_called() { // A `source` that names an implementation (`local.fs_list_files` under the - // key `ls`) is the name the tool is executed with, so the custom parameter - // formatter has to be handed that name too. Handing it the key asks the - // formatter about a tool that does not exist. + // key `ls`) changes the name the tool runs under, but the header the user + // reads stays the name the assistant called. use jp_config::conversation::tool::CommandConfigOrString; let tool_config = ToolConfig::from_partial( @@ -822,23 +805,16 @@ async fn custom_formatter_receives_the_invoked_tool_name() { let mut tools_config = jp_config::AppConfig::new_test().conversation.tools; tools_config.insert("ls".to_owned(), tool_config); - let coordinator = ToolCoordinator::new(tools_config, empty_executor_source()); - let (printer, _stdout, stderr) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - // The formatter is spawned with this path as its working directory, so it - // has to exist on every platform the tests run on. - let root = Utf8TempDir::new().expect("temp dir"); let tool_renderer = ToolRenderer::new( ErrChannel::new(printer.clone()), jp_config::AppConfig::new_test().style, - root.path().to_owned(), - jp_llm::tool::InvocationContext::default(), ); - let outcome = coordinator - .render_approved_tool("ls", &Map::new(), &tool_renderer) - .await; + // The execution service ran the formatter and reported what it printed; + // nothing here shells out to produce this. + let outcome = tool_renderer.render_custom_result("ls", Ok("fs_list_files".into())); match outcome { RenderOutcome::Rendered { content } => { @@ -847,9 +823,75 @@ async fn custom_formatter_receives_the_invoked_tool_name() { RenderOutcome::Suppressed { error } => panic!("custom formatter failed: {error}"), } - // The header the user reads stays the name the assistant called. printer.flush(); - let output = String::from_utf8(strip_ansi_escapes::strip(stderr.lock().as_str())) - .expect("valid utf-8 after stripping ANSI"); - assert_eq!(output, "Calling tool ls\n\nfs_list_files\n"); + assert_eq!( + strip_ansi(&stderr.lock()), + "Calling tool ls\n\nfs_list_files\n" + ); +} + +#[tokio::test] +#[cfg(unix)] +async fn remembered_denial_does_not_run_http_argument_formatter() { + let root = Utf8TempDir::new().unwrap(); + let mut config = AppConfig::new_test(); + let partial = serde_json::from_value(json!({ + "source":"builtin", "run":"ask", "format":"unattended", + "style":{"parameters":{"program":"sh", "args":["-c","printf formatted > formatted"], "shell":false}} + })).unwrap(); + config.conversation.tools.insert( + "example".into(), + ToolConfig::from_partial(partial, vec![]).unwrap(), + ); + let definitions = vec![ToolDefinition { + name: "example".into(), + docs: ToolDocs::default(), + parameters: json!({"type":"object","properties":{}}), + }]; + let (source, owner) = TerminalExecutorSource::start( + BuiltinExecutors::new(), + &definitions, + &config.conversation.tools, + Arc::new(ApprovalStore::default()), + InvocationContext::default(), + &Client::default(), + root.path().to_owned(), + ) + .await + .unwrap(); + let mut coordinator = ToolCoordinator::new(config.conversation.tools.clone(), Box::new(source)); + let executor = coordinator + .prepare_one(ToolCallRequest { + id: "call-1".into(), + name: "example".into(), + arguments: Map::new(), + }) + .unwrap(); + let printer = Arc::new(Printer::sink()); + let prompter = ToolPrompter::with_prompt_backend( + printer.clone(), + None, + Arc::new(MockPromptBackend::new()), + ReplyEditMode::default(), + ); + let renderer = ToolRenderer::new(ErrChannel::new(printer), config.style); + let mut state = TurnState::default(); + state + .remembered_permission_decisions + .insert(PermissionCacheKey::new("example"), false); + let decision = coordinator + .resolve_tool_call_decision(executor, &prompter, true, &mut state, &renderer) + .await; + let ToolCallDecision::Skipped(response) = decision else { + panic!("expected remembered denial") + }; + assert!( + !root.path().join("formatted").exists(), + "a call the user already denied must not run its formatter" + ); + coordinator + .acknowledge_reviews(vec![Review::unchanged(response)]) + .await + .unwrap(); + owner.shutdown().await.unwrap(); } diff --git a/crates/jp_cli/src/cmd/query/tool/executor.rs b/crates/jp_cli/src/cmd/query/tool/executor.rs index f8f0a64b1..2ef79a5a8 100644 --- a/crates/jp_cli/src/cmd/query/tool/executor.rs +++ b/crates/jp_cli/src/cmd/query/tool/executor.rs @@ -1,278 +1,314 @@ -//! Single tool execution for the query stream pipeline. +//! The seam a turn loop runs one tool call through. //! -//! The `ToolExecutor` handles execution of a single tool call, including: +//! [`Executor`] is the MCP Host's view of one logical tool call: preparation +//! and approval precede execution release, and an input request returns control +//! to the Host so it can route the inquiry, review the result, and record both. +//! [`ExecutorSource`] builds one per tool call, so a test can supply a scripted +//! executor where production supplies [`super::mcp_executor`]. //! -//! - Permission prompts (run mode configuration) -//! - Input prompts (tool-specific questions) -//! - Result formatting -//! -//! # Lifecycle State Machine -//! -//! ```text -//! ┌─────────────────────────────────────────────────────┐ -//! │ ToolExecutor │ -//! │ │ -//! ┌─────────┐ │ ┌─────────┐ ┌──────────────────┐ ┌─────────┐ │ -//! │ new() │──────▶│ │ Pending │───▶│AwaitingPermission│───▶│ Running │ │ -//! └─────────┘ │ └─────────┘ └──────────────────┘ └────┬────┘ │ -//! │ │ │ │ -//! │ │ (skip) │ │ -//! │ ▼ ▼ │ -//! │ ┌───────────┐ ┌─────────────┐│ -//! │ │ Completed │◀─────│AwaitingInput││ -//! │ └───────────┘ └─────────────┘│ -//! │ ▲ │ │ -//! │ │ │ │ -//! │ ┌───────────────────┐ │ │ -//! │ │AwaitingResultEdit │◀─────┘ │ -//! │ └───────────────────┘ │ -//! └─────────────────────────────────────────────────────┘ -//! ``` -//! -//! # Thread Safety -//! -//! The executor works with `SharedTurnState` (`Arc>`) to -//! support parallel execution. -//! Lock durations are minimized to avoid blocking other executors. -//! -//! # Testing -//! -//! The [`Executor`] trait allows for mock implementations in tests. -//! See [`MockExecutor`] for testing parallel execution behavior. -//! -//! [`MockExecutor`]: jp_llm::tool::executor::MockExecutor - -use std::sync::Arc; +//! Execution itself lives in `jp_mcp::server`; nothing here runs a tool. use async_trait::async_trait; -use camino::Utf8Path; +use futures::future::BoxFuture; use indexmap::IndexMap; use jp_config::conversation::tool::{RunMode, ToolConfigWithDefaults, ToolSource}; use jp_conversation::event::{InquirySource, ToolCallRequest, ToolCallResponse}; -use jp_llm::{ - ExecutionOutcome, - tool::{ - InvocationContext, StderrSink, ToolDefinition, - builtin::BuiltinExecutors, - executor::{Executor, ExecutorResult, ExecutorSource, PermissionInfo}, - }, -}; -use jp_mcp::Client; -use serde_json::Value; +use jp_llm::query::ToolExecution; +use jp_mcp::server::{StderrSink, service::Formatted}; +use jp_tool::{Question, ToolResult}; +use serde_json::{Map, Value}; use tokio_util::sync::CancellationToken; +use url::Url; -use crate::access::{approvals::ApprovalStore, compile::compile_tool_policy}; +#[path = "executor_error.rs"] +mod error; +pub(crate) use error::ExecutorError; -/// Terminal executor source that creates real [`ToolExecutor`] instances. +/// The MCP Host's view of a logical tool call. /// -/// Holds pre-resolved tool definitions so executors don't need to re-resolve -/// (avoiding redundant MCP server fetches). -pub struct TerminalExecutorSource { - builtin_executors: BuiltinExecutors, - definitions: IndexMap, - approvals: Arc, - invocation: InvocationContext, +/// Preparation and approval precede release. +/// Input and completed results return control to the Host for inquiry routing, +/// result review, and recording. +#[async_trait] +pub(crate) trait Executor: Send + Sync { + /// Prepare an invocation, or return a response resolved without execution. + async fn prepare( + &mut self, + _render_arguments: bool, + ) -> Result, ExecutorError> { + Ok(None) + } + + /// Apply Host approval and wait until the invocation is ready for release. + async fn approve(&mut self) -> Result<(), ExecutorError> { + Ok(()) + } + + /// Custom argument rendering provided by the execution service. + /// + /// `None` when the execution service has not formatted this call's + /// arguments, either because nothing asked it to or because its formatter + /// waits for admission. + fn formatted_arguments(&self) -> Option<&Formatted> { + None + } + + /// Returns the tool call ID. + fn tool_id(&self) -> &str; + + /// Returns the tool name. + fn tool_name(&self) -> &str; + + /// Returns the tool call arguments. + /// + /// This is separate from [`permission_info()`] because arguments are always + /// available, while permission info is only present for tools that require + /// a permission prompt. + /// + /// [`permission_info()`]: Self::permission_info + fn arguments(&self) -> &Map; + + /// Returns information needed for permission prompting. + /// + /// Returns `None` if the tool doesn't need a permission prompt (e.g., + /// `RunMode::Unattended` or `RunMode::Skip`). + fn permission_info(&self) -> Option; + + /// Whether this call needs a permission prompt before it runs. + /// + /// Agrees with [`permission_info()`] being `Some`, without copying the + /// arguments to find out. + /// + /// [`permission_info()`]: Self::permission_info + fn needs_permission(&self) -> bool { + self.permission_info().is_some() + } + + /// Updates the arguments to use for execution. + /// + /// This is called after permission prompting if the user edited the + /// arguments (via `RunMode::Edit`). + /// The new arguments replace the original arguments from the tool call + /// request. + fn set_arguments(&mut self, args: Value); + + /// Hold this call's service-side invocation open while its current attempt + /// is abandoned, so a replacement attempt continues the same logical call. + /// + /// Returns `false` when there is nothing to hold — the service has not + /// named the call yet, or this executor has no service behind it — in + /// which case a restart submits a fresh call instead. + fn pause_for_restart(&self) -> bool { + false + } + + /// Stop this call's current attempt but keep its service-side invocation + /// open, so the response the Host records for it is what the MCP caller + /// receives once that response is acknowledged. + /// + /// Returns `false` when there is nothing to hold, the service has not named + /// the call yet, or this executor has no service behind it. + /// The call is then torn down when its attempt is cancelled. + fn hold_for_response(&self) -> bool { + false + } + + /// Advance the call to its next input request or result. + /// + /// An MCP-backed executor releases prepared work or answers the pending + /// inquiry on its existing MCP call. + /// The server re-executes a tool that returned `NeedsInput`; the executor + /// does not submit another MCP call. + /// The result remains subject to Host review and recording. + /// + /// The executor doesn't know how questions should be answered - it just + /// reports that input is needed. + /// The coordinator looks up the tool configuration to determine whether to + /// prompt the user or ask the LLM. + /// + /// # Arguments + /// + /// - `answers` - Accumulated answers from previous `NeedsInput` responses + /// - `cancellation_token` - Token to cancel execution + /// - `stderr` - Receives the tool's stderr lines as they arrive, for a + /// caller showing progress while it runs. + /// `None` when nothing is watching; the lines still reach tracing and the + /// accumulated buffer either way. + async fn execute( + &self, + answers: &IndexMap, + cancellation_token: CancellationToken, + stderr: Option, + ) -> ExecutorResult; } -impl TerminalExecutorSource { - #[must_use] - pub fn new( - builtin_executors: BuiltinExecutors, - definitions: &[ToolDefinition], - approvals: Arc, - invocation: InvocationContext, - ) -> Self { - let definitions = definitions - .iter() - .map(|d| (d.name.clone(), d.clone())) - .collect(); - Self { - builtin_executors, - definitions, - approvals, - invocation, +/// Creates Host-facing tool calls and acknowledges their recorded responses. +pub(crate) trait ExecutorSource: Send + Sync { + /// The endpoint an external agent submits its own tool calls to. + /// + /// `None` when this source has no reachable endpoint, which is every source + /// that only serves calls JP submits itself. + fn endpoint(&self) -> Option { + None + } + + /// Choose who submits the MCP request for the calls created after this. + /// + /// A source that can only serve calls JP submits refuses anything else, + /// rather than silently accepting work it will never route. + fn set_execution(&self, execution: ToolExecution) -> Result<(), ExecutorError> { + if execution == ToolExecution::Caller { + return Ok(()); } + Err(ExecutorError::ExternalCallsUnsupported) + } + + /// Release a final delivery barrier after the response has been recorded. + /// + /// `review` carries the content the Host settled on, which the executor + /// compares against what it offered to decide whether the Host edited it. + fn acknowledge(&self, _review: Review) -> BoxFuture<'_, Result<(), ExecutorError>> { + Box::pin(async { Ok(()) }) } -} -impl ExecutorSource for TerminalExecutorSource { + /// Creates an executor for the given tool call request. + /// + /// Returns `None` if the tool cannot be resolved (e.g. missing from the + /// definitions). fn create( &self, - mut request: ToolCallRequest, + request: ToolCallRequest, config: ToolConfigWithDefaults, - ) -> Option> { - let definition = self.definitions.get(&request.name)?.clone(); - definition.coerce_arguments(&mut request.arguments); - - Some(Box::new(ToolExecutor::new( - request, - config, - definition, - Arc::new(self.builtin_executors.clone()), - self.approvals.clone(), - self.invocation.clone(), - ))) - } + ) -> Option>; } -/// Executes a single tool call. -/// -/// The executor handles the execution lifecycle including permission prompts, -/// input questions, and result formatting. +/// What the Host settled on for one call, once the conversation has it. /// -/// # Note +/// [`edited`] is what distinguishes a Host that rewrote the text from one that +/// passed it through: only the executor that offered the original knows which +/// happened, so the comparison is made where the original still exists rather +/// than by projecting both to text and comparing strings. /// -/// Interactive prompts currently happen inside `ToolDefinition::call()`. -/// In the future, prompts will be driven by the `ToolCoordinator`, and the -/// executor will only handle pure execution. -pub struct ToolExecutor { - request: ToolCallRequest, - config: ToolConfigWithDefaults, - definition: ToolDefinition, - builtin_executors: Arc, - approvals: Arc, - invocation: InvocationContext, +/// [`edited`]: Self::edited +#[derive(Debug, Clone)] +pub(crate) struct Review { + /// The response the conversation recorded. + pub response: ToolCallResponse, + + /// Whether the Host changed the content it was offered. + pub edited: bool, } -impl ToolExecutor { - fn new( - request: ToolCallRequest, - config: ToolConfigWithDefaults, - definition: ToolDefinition, - builtin_executors: Arc, - approvals: Arc, - invocation: InvocationContext, - ) -> Self { +impl Review { + /// The Host recorded the content it was offered. + pub fn unchanged(response: ToolCallResponse) -> Self { Self { - request, - config, - definition, - builtin_executors, - approvals, - invocation, + response, + edited: false, } } - /// Resolve the persisted `InquirySource` recorded for a question this tool - /// emits. - /// - /// Built-in tools may override their source via - /// `BuiltinTool::inquiry_source`; local and MCP tools always attribute the - /// question to the tool by name. - fn inquiry_source(&self) -> InquirySource { - match self.config.source() { - ToolSource::Builtin { .. } => { - self.builtin_executors.get(&self.request.name).map_or_else( - || InquirySource::tool(self.request.name.as_str()), - |tool| tool.inquiry_source(&self.request.name), - ) - } - ToolSource::Local { .. } | ToolSource::Mcp { .. } => { - InquirySource::tool(self.request.name.as_str()) - } + /// The Host recorded content of its own in place of what it was offered. + pub fn replaced(response: ToolCallResponse) -> Self { + Self { + response, + edited: true, } } } -#[async_trait] -impl Executor for ToolExecutor { - fn tool_id(&self) -> &str { - &self.request.id +/// Project a tool result into the conversation's text/error format. +/// +/// This is the compatibility projection: the conversation stores one string per +/// call plus a failure flag, so ordered content, resources, and structured data +/// are flattened by [`ToolResult::to_text`] and the failure flag becomes `Err`. +pub(crate) fn response(id: impl Into, result: &ToolResult) -> ToolCallResponse { + let text = result.to_text(); + ToolCallResponse { + id: id.into(), + result: if result.is_error() { + Err(text) + } else { + Ok(text) + }, } +} - fn tool_name(&self) -> &str { - &self.request.name - } +/// Result of a tool execution attempt. +/// +/// Tools may need multiple rounds of execution if they require additional +/// input. +/// This enum allows the executor to return control to the coordinator, which +/// decides how to handle the `NeedsInput` case by looking up the question +/// configuration. +#[derive(Debug)] +#[expect( + clippy::large_enum_variant, + reason = "A turn holds one of these per in-flight call, not a collection of them" +)] +pub(crate) enum ExecutorResult { + /// Tool completed (success or error). + /// + /// The full result stays with the executor, which hands it back unchanged + /// if the Host records this response without editing it. + Completed(ToolCallResponse), - fn arguments(&self) -> &serde_json::Map { - &self.request.arguments - } + /// The call could not be advanced, and nothing ran. + /// + /// Distinct from a tool that ran and reported failure: the reason is JP's + /// own machinery, not the tool's, so it is not content for the model to + /// reason about. + Failed(ExecutorError), - fn permission_info(&self) -> Option { - let run_mode = self.config.run(); + /// Tool needs additional input before it can continue. + /// + /// The executor doesn't know who should answer - it just reports that input + /// is needed. + /// The coordinator looks up the question configuration to determine the + /// target: + /// + /// - `User`: Prompt the user interactively, then restart the tool + /// - `Assistant`: Format a response asking the LLM to re-run with answers + NeedsInput { + /// Tool call ID. + tool_id: String, - // No prompt needed for these modes - if matches!(run_mode, RunMode::Unattended | RunMode::Skip) { - return None; - } + /// Tool name (for persisting answers). + tool_name: String, - Some(PermissionInfo { - tool_id: self.request.id.clone(), - tool_name: self.request.name.clone(), - tool_source: self.config.source().clone(), - run_mode, - arguments: self.request.arguments.clone().into(), - }) - } + /// The question that needs to be answered. + question: Question, - fn set_arguments(&mut self, args: Value) { - if let Value::Object(map) = args { - self.request.arguments = map; - } - // If not an object, ignore (preserve original arguments) - } + /// Resolved provenance for the persisted `InquiryRequest`. + source: InquirySource, - async fn execute( - &self, - answers: &IndexMap, - mcp_client: &Client, - root: &Utf8Path, - cancellation_token: CancellationToken, - stderr: Option, - ) -> ExecutorResult { - // Compile this tool's access grants into a runtime policy, baking - // approved external targets in. The policy travels to the tool in its - // context so the tool can self-enforce. A policy that fails to compile - // (invalid config) fails the tool rather than running it unenforced. - let access = match compile_tool_policy(self.config.access(), root, &self.approvals) { - Ok(access) => access, - Err(error) => { - return ExecutorResult::Completed(ToolCallResponse { - id: self.request.id.clone(), - result: Err(format!( - "invalid access policy for tool '{}': {error}", - self.request.name - )), - }); - } - }; - - let result = self - .definition - .execute( - self.request.id.clone(), - Value::Object(self.request.arguments.clone()), - answers, - &self.config, - mcp_client, - root, - cancellation_token, - &self.builtin_executors, - access.as_ref(), - &self.invocation, - stderr, - ) - .await; - - match result { - Ok(ExecutionOutcome::Completed { id, result }) => { - ExecutorResult::Completed(ToolCallResponse { id, result }) - } - Ok(ExecutionOutcome::Cancelled { id }) => ExecutorResult::Completed(ToolCallResponse { - id, - result: Ok("Tool execution cancelled.".to_string()), - }), - Ok(ExecutionOutcome::NeedsInput { id: _, question }) => ExecutorResult::NeedsInput { - tool_id: self.request.id.clone(), - tool_name: self.request.name.clone(), - question, - source: self.inquiry_source(), - accumulated_answers: answers.clone(), - }, - Err(e) => ExecutorResult::Completed(ToolCallResponse { - id: self.request.id.clone(), - result: Err(e.to_string()), - }), - } - } + /// Accumulated answers so far (for retry). + accumulated_answers: IndexMap, + }, } + +/// Information needed to prompt for tool execution permission. +/// +/// This struct contains all the data the `ToolPrompter` needs to show a +/// permission prompt to the user. +#[derive(Debug, Clone)] +pub(crate) struct PermissionInfo { + /// The tool call ID. + pub tool_id: String, + + /// The tool name. + pub tool_name: String, + + /// The tool source (builtin, local, MCP). + pub tool_source: ToolSource, + + /// The configured run mode. + pub run_mode: RunMode, + + /// The arguments to pass to the tool. + pub arguments: Value, +} + +#[cfg(test)] +#[path = "executor_mock.rs"] +pub(crate) mod mock; diff --git a/crates/jp_cli/src/cmd/query/tool/executor_error.rs b/crates/jp_cli/src/cmd/query/tool/executor_error.rs new file mode 100644 index 000000000..90c60d19f --- /dev/null +++ b/crates/jp_cli/src/cmd/query/tool/executor_error.rs @@ -0,0 +1,98 @@ +//! Failures while the MCP Host advances a logical tool call. + +use std::error::Error as StdError; + +use serde_json::Error as JsonError; +use tokio::task::JoinError; + +/// A tool-call adapter failed before completing its Host protocol. +#[derive(Debug, thiserror::Error)] +pub(crate) enum ExecutorError { + /// The service lost its Host interaction channel. + #[error("MCP Host interaction channel closed")] + HostDisconnected, + + /// This executor source only serves calls the Host itself submits. + #[error("executor source does not support external MCP calls")] + ExternalCallsUnsupported, + + /// An agent announced a tool call but never submitted it to the endpoint. + /// + /// The two arrive over independent transports, so the Host waits rather + /// than assuming an ordering; this is that wait running out. + #[error("external tool call did not reach the JP MCP Server within 30 seconds")] + ExternalCallTimeout, + + /// Result metadata could not be decoded into the shared result contract. + #[error("Invalid tool result: {0}")] + MalformedResult(#[source] JsonError), + + /// The transport task terminated unexpectedly. + #[error(transparent)] + Task(#[from] JoinError), + + /// An MCP request failed. + #[error("{0}")] + Transport(#[source] Box), + + /// The call stopped before the Host's reply reached the service. + /// + /// `operation` names the barrier that lapsed: `approval`, `release`, or + /// `inquiry`. + #[error("MCP {operation} expired")] + ReplyExpired { + /// The barrier the Host was answering. + operation: &'static str, + }, + + /// No answer exists for the outstanding question. + #[error("Missing answer to pending MCP inquiry")] + MissingAnswer, + + /// The service rejected execution with a tool diagnostic. + #[error("{message}")] + Rejected { + /// The diagnostic to report in place of a result. + message: String, + }, + + /// The call was cancelled by the Host. + #[error("Tool execution cancelled.")] + Cancelled, + + /// The Host asked for something this call's phase cannot do. + #[error("MCP call cannot {operation} while {phase}")] + OutOfOrder { + /// What the Host asked for. + operation: &'static str, + /// The phase the call is in. + phase: &'static str, + }, + + /// The service asked for an interaction outside the expected sequence. + /// + /// Reaching this means the service and this adapter disagree about the + /// interaction protocol, not that a tool or the user did anything wrong. + #[error("Unexpected MCP interaction while {phase} a tool call")] + UnexpectedInteraction { + /// What the adapter was doing. + phase: &'static str, + }, + + /// The content the caller received differs from the content recorded. + #[error("MCP response differs from the recorded response")] + DeliveryMismatch, +} + +impl ExecutorError { + /// Whether this belongs in the conversation as the call's outcome. + /// + /// A user stopping a tool is something that happened to the call, and the + /// model needs to know it. + /// Everything else here is the Host and the execution service failing to + /// agree, which is JP's problem to report to the user rather than the + /// model's to reason about. + pub(crate) fn is_call_outcome(&self) -> bool { + matches!(self, Self::Cancelled) + } +} diff --git a/crates/jp_cli/src/cmd/query/tool/executor_mock.rs b/crates/jp_cli/src/cmd/query/tool/executor_mock.rs new file mode 100644 index 000000000..358e41cfa --- /dev/null +++ b/crates/jp_cli/src/cmd/query/tool/executor_mock.rs @@ -0,0 +1,185 @@ +//! Executors that return a scripted result instead of running anything. + +use std::{collections::HashMap, sync::Mutex}; + +use async_trait::async_trait; +use indexmap::IndexMap; +use jp_config::conversation::tool::ToolConfigWithDefaults; +use jp_conversation::event::{ToolCallRequest, ToolCallResponse}; +use jp_mcp::server::StderrSink; +use jp_tool::{ToolDefinition, ToolDocs}; +use serde_json::{Map, Value, json}; +use tokio_util::sync::CancellationToken; + +use super::{Executor, ExecutorResult, ExecutorSource, PermissionInfo}; + +/// A mock executor for testing that returns pre-configured results. +/// +/// This executor doesn't execute any real commands - it simply returns whatever +/// result is configured, making it ideal for testing tool coordination flows +/// without side effects. +/// +/// # Example +/// +/// ```ignore +/// let executor = MockExecutor::completed("call_1", "my_tool", "success output"); +/// let result = executor.execute(&answers, &client, &root, token, None).await; +/// ``` +pub(crate) struct MockExecutor { + tool_id: String, + tool_name: String, + arguments: Map, + permission_info: Option, + result: Mutex>, +} + +impl MockExecutor { + /// Creates a mock executor that returns a successful completion. + pub(crate) fn completed(tool_id: &str, tool_name: &str, output: &str) -> Self { + Self::new(tool_id, tool_name, Ok(output.to_owned())) + } + + /// Creates a mock executor that returns an error. + pub(crate) fn error(tool_id: &str, tool_name: &str, error: &str) -> Self { + Self::new(tool_id, tool_name, Err(error.to_owned())) + } + + fn new(tool_id: &str, tool_name: &str, result: Result) -> Self { + Self { + tool_id: tool_id.to_owned(), + tool_name: tool_name.to_owned(), + arguments: Map::new(), + permission_info: None, + result: Mutex::new(Some(ExecutorResult::Completed(ToolCallResponse { + id: tool_id.to_owned(), + result, + }))), + } + } + + /// Sets the arguments for this executor. + pub(crate) fn with_arguments(mut self, args: Map) -> Self { + self.arguments = args; + self + } + + /// Sets the permission info for this executor. + /// + /// If set, the executor will require permission prompting based on the + /// configured `RunMode`. + pub(crate) fn with_permission_info(mut self, info: PermissionInfo) -> Self { + self.permission_info = Some(info); + self + } +} + +#[async_trait] +impl Executor for MockExecutor { + fn tool_id(&self) -> &str { + &self.tool_id + } + + fn tool_name(&self) -> &str { + &self.tool_name + } + + fn arguments(&self) -> &Map { + &self.arguments + } + + fn permission_info(&self) -> Option { + self.permission_info.clone() + } + + fn set_arguments(&mut self, _args: Value) { + // Arguments don't affect the pre-configured result. + } + + async fn execute( + &self, + _answers: &IndexMap, + _cancellation_token: CancellationToken, + _stderr: Option, + ) -> ExecutorResult { + self.result + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .unwrap_or_else(|| { + ExecutorResult::Completed(ToolCallResponse { + id: self.tool_id.clone(), + result: Err("MockExecutor: result already consumed".to_owned()), + }) + }) + } +} + +/// An executor source for testing that returns pre-registered mock executors. +/// +/// This allows tests to inject mock executors for specific tool names without +/// executing any real shell commands. +/// +/// # Example +/// +/// ```ignore +/// let source = TestExecutorSource::new() +/// .with_executor("my_tool", |req| { +/// Box::new(MockExecutor::completed(&req.id, &req.name, "mock output")) +/// }); +/// +/// let coordinator = ToolCoordinator::new(tools_config, Box::new(source)); +/// ``` +#[derive(Default)] +pub(crate) struct TestExecutorSource { + #[expect( + clippy::type_complexity, + reason = "A boxed factory per tool name, named inline rather than aliased once" + )] + factories: HashMap Box + Send + Sync>>, +} + +impl TestExecutorSource { + /// Creates a new empty test executor source. + pub(crate) fn new() -> Self { + Self::default() + } + + /// Registers a factory function for a tool name. + /// + /// When `create()` is called for this tool name, the factory will be + /// invoked to create the executor. + pub(crate) fn with_executor(mut self, tool_name: &str, factory: F) -> Self + where + F: Fn(ToolCallRequest) -> Box + Send + Sync + 'static, + { + self.factories + .insert(tool_name.to_owned(), Box::new(factory)); + self + } + + /// Returns stub [`ToolDefinition`]s for all registered tool names. + /// + /// Useful for passing to `run_turn_loop` so the availability check accepts + /// the tools this source can handle. + pub(crate) fn tool_definitions(&self) -> Vec { + self.factories + .keys() + .map(|name| ToolDefinition { + name: name.clone(), + docs: ToolDocs::default(), + parameters: json!({ "type": "object", "properties": {} }), + }) + .collect() + } +} + +impl ExecutorSource for TestExecutorSource { + fn create( + &self, + request: ToolCallRequest, + _config: ToolConfigWithDefaults, + ) -> Option> { + let factory = self.factories.get(&request.name)?; + Some(factory(request)) + } +} diff --git a/crates/jp_cli/src/cmd/query/tool/inquiry.rs b/crates/jp_cli/src/cmd/query/tool/inquiry.rs index f127e6c24..9c9f576d4 100644 --- a/crates/jp_cli/src/cmd/query/tool/inquiry.rs +++ b/crates/jp_cli/src/cmd/query/tool/inquiry.rs @@ -32,10 +32,9 @@ use jp_llm::{ model::ModelDetails, query::{ChatQuery, Truncation}, retry::{RetryConfig, collect_with_retry}, - tool::ToolDefinition, window, }; -use jp_tool::{AnswerType, Question}; +use jp_tool::{AnswerType, Question, ToolDefinition}; use serde_json::{Map, Value, json}; use tokio_util::sync::CancellationToken; use tracing::info; @@ -219,7 +218,6 @@ impl LlmInquiryBackend { } #[async_trait] -#[allow(clippy::too_many_lines)] impl InquiryBackend for LlmInquiryBackend { async fn inquire( &self, diff --git a/crates/jp_cli/src/cmd/query/tool/mcp_executor.rs b/crates/jp_cli/src/cmd/query/tool/mcp_executor.rs new file mode 100644 index 000000000..b1bd2984d --- /dev/null +++ b/crates/jp_cli/src/cmd/query/tool/mcp_executor.rs @@ -0,0 +1,1234 @@ +//! MCP Host adapter for tool calls through JP's loopback HTTP endpoint. +//! +//! [`TerminalExecutorSource`] owns the endpoint, the Host's MCP connection, and +//! the task that routes the service's private interactions back to the tool +//! call that submitted them. +//! Each [`ToolExecutor`] drives one logical call: it holds the single-use Host +//! reply the service is waiting on, and hands the coordinator an +//! [`ExecutorResult`] whenever the call produces something the conversation +//! should record. + +use std::{ + collections::HashMap, + mem, + sync::{ + Arc, Mutex as SyncMutex, MutexGuard, PoisonError, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +use async_trait::async_trait; +use camino::Utf8PathBuf; +use futures::future::BoxFuture; +use indexmap::IndexMap; +use jp_config::conversation::tool::{RunMode, ToolConfigWithDefaults, ToolsConfig}; +use jp_conversation::event::{InquirySource, ToolCallRequest, ToolCallResponse}; +use jp_llm::query::ToolExecution; +use jp_mcp::{ + Client, + server::{ + StderrSink, + builtin::BuiltinExecutors, + http::{Endpoint, EndpointError}, + result::from_mcp, + service::{ + AccessPolicyError, Admission, ConfiguredTool, Formatted, HostReply, HostRequest, + InputAnswer, Interaction, InvocationId, Progress, ReleaseDecision, Service, + }, + }, +}; +use jp_tool::{ + ContentBlock, InputRequest, InvocationContext, Question, QuestionId, ToolDefinition, ToolResult, +}; +use rand::random; +use rmcp::{ + Peer, ServiceError as McpCallError, + model::{CallToolRequestParams, Meta}, + service::{RoleClient, RunningService}, +}; +use serde_json::{Map, Value}; +use tokio::{ + sync::{Mutex, Notify, broadcast, broadcast::error::RecvError as ProgressError, mpsc, oneshot}, + task::JoinHandle, + time::timeout, +}; +use tokio_util::sync::CancellationToken; +use tracing::{debug, warn}; +use url::Url; + +use super::executor::{ + Executor, ExecutorError, ExecutorResult, ExecutorSource, PermissionInfo, Review, response, +}; +use crate::access::{approvals::ApprovalStore, compile::compile_tool_policy}; + +/// Where a call's correlation key travels on its MCP request when JP submits it +/// itself. +/// +/// The value is generated by the Host and never derived from anything a caller +/// supplies, so a third-party MCP client cannot name someone else's call. +const CORRELATION_KEY: &str = "computer.jp/hostCall"; + +/// How long an announced agent call may take to reach the endpoint. +/// +/// The announcement and the MCP request travel on independent transports, so +/// either can arrive first; this bounds the wait for the one that is late. +const AGENT_CALL_TIMEOUT: Duration = Duration::from_secs(30); + +/// How many unroutable agent requests the router holds while their +/// announcements catch up. +const DEFERRED_LIMIT: usize = 64; + +type Reply = oneshot::Sender>; + +fn locked(value: &SyncMutex) -> MutexGuard<'_, T> { + // No caller code runs under these locks, so recovering a poisoned one lets + // an unrelated panic elsewhere finish this turn's calls rather than hang + // them. + value.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// The value that names one call in the correlation metadata. +/// +/// Which side mints it depends on who submits the MCP request. +/// When JP submits it, the key is a random value only JP knows, so nothing a +/// caller supplies can name someone else's call. +/// When an agent submits it, the agent chose the identifier before JP saw the +/// call, so the key is that identifier and the guarantee is weaker: an agent +/// that reuses one collides with itself. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct HostCallKey(String); + +impl HostCallKey { + /// A key for a call JP submits. + fn generate() -> Self { + Self(format!("{:032x}", random::())) + } + + /// The key for a call an agent submits, which is its own tool-call id. + fn agent(tool_call_id: &str) -> Self { + Self(tool_call_id.to_owned()) + } + + /// The key for a call under `execution`, given the tool-call id. + fn for_execution(execution: ToolExecution, tool_call_id: &str) -> Self { + match execution { + ToolExecution::Caller => Self::generate(), + ToolExecution::Agent { .. } => Self::agent(tool_call_id), + } + } +} + +/// Who submits the MCP request for each call, and a way to wake the router when +/// a route appears. +/// +/// The two travel together because they are always used together: a call +/// registers under whichever contract is current, and the router has to +/// reconsider anything it could not place when that happens. +#[derive(Default)] +struct Dispatch { + mode: SyncMutex, + registered: Notify, +} + +impl Dispatch { + fn mode(&self) -> ToolExecution { + *locked(&self.mode) + } + + /// The metadata field carrying the call identifier under the current + /// contract. + fn correlation_field(&self) -> &'static str { + match self.mode() { + ToolExecution::Caller => CORRELATION_KEY, + ToolExecution::Agent { correlation_key } => correlation_key, + } + } +} + +/// Everything the Host keeps for one logical tool call. +struct CallSlot { + /// The correlation key the service echoes back on every interaction. + key: HostCallKey, + + /// The call as the assistant requested it. + /// Argument edits do not change it. + request: ToolCallRequest, + + /// Where the router forwards this call's interactions. + sender: mpsc::Sender, + + /// The service's own name for this call, learned from its first + /// interaction. + /// + /// Set once and kept for the life of the call, including across a restart: + /// pausing and resuming an invocation needs the name the service already + /// knows it by. + invocation: SyncMutex>, + + /// Whether this call is between a paused attempt and its replacement. + /// + /// Set by [`Executor::pause_for_restart`] and cleared by the `prepare` that + /// follows, so that `prepare` reuses the invocation instead of rejecting a + /// second submission, and the cancellation that ends the paused attempt + /// does not tear the service-side call down. + restarting: AtomicBool, + + /// Whether this call's attempt was stopped so the Host can answer it with + /// the response it records instead. + /// + /// Set by [`Executor::hold_for_response`] and cleared by the + /// acknowledgement that delivers that response. + /// While set, the cancellation that ends the attempt leaves the + /// service-side call open. + held: AtomicBool, + + /// Where to show this tool's stderr, for the length of one attempt. + /// + /// Set when an attempt starts and cleared when it ends, so a display that + /// has moved on is never written to by whatever runs next under this + /// invocation. + /// `None` whenever no attempt is in flight, or when nothing is watching + /// this one. + stderr: SyncMutex>, + + /// The call's protocol phase and the Host reply it is parked on. + /// + /// Held for the whole of every operation that advances the call, including + /// across the await on the service, so two operations on one call are + /// serialised rather than interleaved. + /// Ordering is not left to that serialisation: [`Phase`] is what makes an + /// operation arriving out of turn an [`ExecutorError::OutOfOrder`] instead + /// of a silently wrong reply. + /// + /// The cost is that acknowledging a call while it is still executing waits + /// for the execution attempt to finish. + /// The coordinator never does that, because it acknowledges only after the + /// conversation has the response. + state: Mutex, +} + +/// The calls a turn has in flight, under the two names they are looked up by. +/// +/// Both maps hold the same slots: the router resolves an interaction by the +/// correlation key it carries, and acknowledgement resolves a recorded response +/// by the tool call id the conversation stores. +#[derive(Default)] +struct Registry { + by_key: HashMap>, + by_id: HashMap>, +} + +impl Registry { + fn insert(&mut self, slot: &Arc) { + self.by_key.insert(slot.key.clone(), slot.clone()); + self.by_id.insert(slot.request.id.clone(), slot.clone()); + } + + /// Claim a call for acknowledgement, leaving its route in place. + /// + /// The service still has barriers to raise before the call finishes, and a + /// request it cannot route fails closed, so the route outlives the claim. + fn claim(&mut self, id: &str) -> Option> { + self.by_id.remove(id) + } + + /// The call `id` names, if it is waiting to be restarted. + fn restarting(&self, id: &str) -> Option> { + self.by_id + .get(id) + .filter(|slot| slot.restarting.load(Ordering::Acquire)) + .cloned() + } + + /// Drop a finished call's route. + fn forget(&mut self, slot: &CallSlot) { + self.by_key.remove(&slot.key); + } +} + +/// Creates MCP-backed executors; configuration and execution context are fixed +/// by the MCP Host when starting the endpoint. +pub(crate) struct TerminalExecutorSource { + peer: Peer, + service: Arc, + definitions: IndexMap, + calls: Arc>, + + /// Where an agent submits its own tool requests. + endpoint: Url, + + /// Who submits each call, and the router's wake-up for new routes. + dispatch: Arc, +} + +/// Keeps the listener, MCP connection, and Host routing task alive for a turn. +pub(crate) struct ExecutionOwner { + pub(super) endpoint: Option, + + /// The Host's own connection, closed before the listener stops. + /// + /// Reachable from the shutdown test, which retires this one and puts a + /// connection it can observe in its place. + pub(super) client: Option>, + + router: JoinHandle<()>, +} + +impl ExecutionOwner { + /// Cancel pending work and wait for listener/connection cleanup. + /// + /// The order matters: admission stops first so nothing new is accepted, + /// then the client closes its MCP session, which is an HTTP request the + /// listener still has to serve, and only then does the listener go. + pub(crate) async fn shutdown(mut self) -> Result<(), EndpointError> { + if let Some(endpoint) = &self.endpoint { + endpoint.service().stop(); + } + let client = match self.client.take() { + Some(client) => client.cancel().await.map(drop), + None => Ok(()), + }; + let server = match self.endpoint.take() { + Some(endpoint) => endpoint.shutdown().await, + None => Ok(()), + }; + self.router.abort(); + client?; + server + } +} + +impl Drop for ExecutionOwner { + fn drop(&mut self) { + self.router.abort(); + } +} + +impl TerminalExecutorSource { + /// Start the execution path without provider-supplied description hints. + #[cfg(test)] + pub(crate) async fn start( + builtins: BuiltinExecutors, + definitions: &[ToolDefinition], + tools: &ToolsConfig, + approvals: Arc, + invocation: InvocationContext, + upstream: &Client, + root: Utf8PathBuf, + ) -> Result<(Self, ExecutionOwner), EndpointError> { + Self::start_with_metadata( + builtins, + definitions, + tools, + approvals, + invocation, + upstream, + root, + Map::new(), + ) + .await + } + + /// Start the common MCP execution path and its private Host connection. + /// + /// `metadata` is advertised on every tool's MCP description, for a caller + /// that reads vendor hints there. + /// It changes no execution policy. + pub(crate) async fn start_with_metadata( + builtins: BuiltinExecutors, + definitions: &[ToolDefinition], + tools: &ToolsConfig, + approvals: Arc, + invocation: InvocationContext, + upstream: &Client, + root: Utf8PathBuf, + metadata: Map, + ) -> Result<(Self, ExecutionOwner), EndpointError> { + let configured = definitions + .iter() + .filter_map(|definition| { + let config = tools.get(&definition.name)?; + let access = + compile_tool_policy(config.access(), &root, &approvals).map_err(|error| { + AccessPolicyError { + tool: definition.name.clone(), + source: Arc::new(error), + } + }); + Some(ConfiguredTool { + definition: definition.clone(), + config, + access, + metadata: metadata.clone(), + }) + }) + .collect(); + let (service, host) = + Service::new(configured, upstream.clone(), builtins, root, invocation)?; + let progress = service.subscribe_progress(); + let endpoint = Endpoint::start(service).await?; + let client = endpoint.connect().await?; + let calls = Arc::new(SyncMutex::new(Registry::default())); + let dispatch = Arc::new(Dispatch::default()); + let router = tokio::spawn(route(host, progress, calls.clone(), dispatch.clone())); + let source = Self { + peer: client.peer().clone(), + service: endpoint.service(), + definitions: definitions + .iter() + .map(|definition| (definition.name.clone(), definition.clone())) + .collect(), + calls, + endpoint: endpoint + .url() + .parse() + .expect("the endpoint binds a loopback HTTP URL"), + dispatch, + }; + Ok((source, ExecutionOwner { + endpoint: Some(endpoint), + client: Some(client), + router, + })) + } +} + +/// Deliver the service's interactions and stderr to the calls they belong to. +/// +/// Both streams are drained by one task so a display that stops reading cannot +/// delay a required interaction: progress is dropped, interactions are not. +async fn route( + mut host: mpsc::Receiver, + mut progress: broadcast::Receiver, + calls: Arc>, + dispatch: Arc, +) { + // The service names a call by an `InvocationId` the Host only learns from + // that call's first interaction, so this index is built here rather than at + // registration. Being task-local it needs no lock, and it is bounded by the + // turn's tool calls: the task is aborted when the turn's owner shuts down. + let mut by_invocation: HashMap> = HashMap::new(); + + // Requests whose call has not registered yet. Only possible when an agent + // submits: its MCP request and the announcement JP learns the call from + // travel on independent transports, so either can arrive first. + let mut deferred: Vec = Vec::new(); + let mut watching_progress = true; + + loop { + let arrived = tokio::select! { + request = host.recv() => match request { + Some(request) => Some(request), + None => break, + }, + // A call registered, so anything held back may now have a route. + () = dispatch.registered.notified() => None, + line = progress.recv(), if watching_progress => { + match line { + Ok(line) => { + let sink = by_invocation + .get(&line.id) + .and_then(|slot| locked(&slot.stderr).clone()); + if let Some(sink) = sink { + sink(&line.line); + } + } + // A display that fell behind loses stderr lines. It never + // delays the interactions in the other branch, which is the + // point of keeping progress on its own channel. + Err(ProgressError::Lagged(_)) => {} + Err(ProgressError::Closed) => watching_progress = false, + } + continue; + } + }; + + let mut queued = mem::take(&mut deferred); + queued.extend(arrived); + for request in queued { + let Some(slot) = resolve(&calls, &dispatch, &request) else { + if dispatch.mode() != ToolExecution::Caller && deferred.len() < DEFERRED_LIMIT { + deferred.push(request); + continue; + } + // The reply sender drops with the request, so the service + // sees the Host decline and fails the call closed. + warn!( + tool = %request.call.request.name, + invocation = ?request.call.id, + "No Host route for this MCP invocation; failing it closed." + ); + continue; + }; + by_invocation.insert(request.call.id, slot.clone()); + drop(slot.sender.send(request).await); + } + } +} + +/// Find the call an interaction belongs to, if it names one. +/// +/// The correlation key is what identifies the call, and which metadata field +/// carries it depends on who submitted the request: JP's own field when JP +/// submits, the agent's when it does. +/// The service echoes whichever arrived back untouched, so an interaction +/// without a key naming a registered call gets no route. +/// +/// The name and arguments are checked afterwards as a consistency assertion, +/// not as a second authority: anything able to supply the key could supply +/// these too. +/// They catch the service echoing the wrong correlation map, which would +/// otherwise show up as a tool that silently never finishes. +fn resolve( + calls: &SyncMutex, + dispatch: &Dispatch, + request: &HostRequest, +) -> Option> { + let key = request + .call + .request + .correlation + .get(dispatch.correlation_field()) + .and_then(Value::as_str) + .map(|value| HostCallKey(value.to_owned()))?; + let slot = locked(calls).by_key.get(&key).cloned()?; + if slot.request.name != request.call.request.name + || slot.request.arguments != request.call.request.arguments + { + warn!( + tool_call_id = %slot.request.id, + expected = %slot.request.name, + received = %request.call.request.name, + "Correlation key names a call whose request does not match; refusing the route." + ); + return None; + } + let mut invocation = locked(&slot.invocation); + match *invocation { + Some(id) if id != request.call.id => { + warn!( + tool_call_id = %slot.request.id, + bound = ?id, + received = ?request.call.id, + "Correlation key is already bound to another invocation; refusing the route." + ); + return None; + } + Some(_) => {} + None => { + debug!( + invocation = ?request.call.id, + tool_call_id = %slot.request.id, + tool = %slot.request.name, + "Associated MCP invocation with Host tool call" + ); + *invocation = Some(request.call.id); + } + } + drop(invocation); + Some(slot) +} + +impl ExecutorSource for TerminalExecutorSource { + fn endpoint(&self) -> Option { + Some(self.endpoint.clone()) + } + + fn set_execution(&self, execution: ToolExecution) -> Result<(), ExecutorError> { + *locked(&self.dispatch.mode) = execution; + // A held-back request may be routable under the new contract. + self.dispatch.registered.notify_one(); + Ok(()) + } + + fn create( + &self, + request: ToolCallRequest, + config: ToolConfigWithDefaults, + ) -> Option> { + self.definitions.get(&request.name)?; + + // A restarted call keeps its slot, and with it the service-side + // invocation the paused attempt belongs to. Building a second slot + // would strand that invocation and submit a duplicate call. + if let Some(slot) = locked(&self.calls).restarting(&request.id) { + return Some(Box::new(ToolExecutor { + arguments: slot.request.arguments.clone(), + config, + peer: self.peer.clone(), + service: self.service.clone(), + slot, + formatted: None, + })); + } + + let execution = self.dispatch.mode(); + let (sender, receiver) = mpsc::channel(8); + let slot = Arc::new(CallSlot { + key: HostCallKey::for_execution(execution, &request.id), + request, + sender, + invocation: SyncMutex::new(None), + restarting: AtomicBool::new(false), + held: AtomicBool::new(false), + stderr: SyncMutex::new(None), + state: Mutex::new(PendingCall { + receiver, + task: None, + phase: Phase::Idle, + execution, + service: self.service.clone(), + invocation: None, + }), + }); + locked(&self.calls).insert(&slot); + // An agent's MCP request may already be waiting on this route. + self.dispatch.registered.notify_one(); + Some(Box::new(ToolExecutor { + arguments: slot.request.arguments.clone(), + config, + peer: self.peer.clone(), + service: self.service.clone(), + slot, + formatted: None, + })) + } + + fn acknowledge(&self, review: Review) -> BoxFuture<'_, Result<(), ExecutorError>> { + Box::pin(async move { + // Claiming makes a second acknowledgement a no-op. Forgetting the + // call afterwards is what bounds the registry; a turn that ends + // without acknowledging every call drops the whole source. + let Some(slot) = locked(&self.calls).claim(&review.response.id) else { + return Ok(()); + }; + let result = slot.acknowledge(&review).await; + locked(&self.calls).forget(&slot); + result + }) + } +} + +/// The Host reply a call is currently parked on. +/// +/// Exactly one is outstanding at a time: the service asks for the next thing +/// only once the previous reply reaches it. +enum Phase { + /// Created, but the MCP call has not been submitted. + Idle, + + /// Submitted; the Host owes an admission decision. + Admission(Reply), + + /// Admitted; the Host owes execution release. + Release(Reply), + + /// A tool asked for input; the Host owes an answer. + Input { + id: QuestionId, + reply: Reply, + }, + + /// A result is waiting for the Host to approve or replace its content. + /// + /// `offered` is what the service produced. + /// A Host that does not edit gets this value back, so resources, + /// annotations, structured content, and error details survive a review that + /// changed nothing. + Review { + offered: ToolResult, + reply: Reply, + }, + + /// Content is waiting for the Host to confirm it recorded it. + Record(Reply<()>), + + /// The MCP call returned; nothing is outstanding. + Finished, +} + +impl Phase { + /// How this phase reads in a protocol diagnostic. + fn name(&self) -> &'static str { + match self { + Self::Idle => "not yet submitted", + Self::Admission(_) => "awaiting admission", + Self::Release(_) => "awaiting release", + Self::Input { .. } => "awaiting input", + Self::Review { .. } => "awaiting result review", + Self::Record(_) => "awaiting recording", + Self::Finished => "finished", + } + } +} + +struct PendingCall { + receiver: mpsc::Receiver, + + /// The MCP request JP submitted, when JP is the one submitting. + /// `None` for a call an agent submits: the request is already in flight + /// somewhere JP cannot await. + task: Option>>, + + phase: Phase, + + /// Who submits this call's MCP request, fixed when the call was created. + execution: ToolExecution, + + /// Reached to observe cancellation of an agent-submitted call, which has no + /// task to select on. + service: Arc, + + /// The service's name for this call, once an interaction has carried it. + invocation: Option, +} + +impl PendingCall { + /// Whether somebody other than JP holds this call's MCP request, and so + /// receives its response. + fn submitted_elsewhere(&self) -> bool { + matches!(self.execution, ToolExecution::Agent { .. }) + } +} + +enum Received { + /// Boxed because a call holds one of these only while dispatching it, and + /// the largest variant is several times the size of the rest. + Interaction(Box), + + /// The MCP call returned its final result. + Finished(ToolResult), +} + +impl PendingCall { + /// Wait for the next Host interaction the service still cares about, or for + /// the MCP call to return. + /// + /// An interaction the service has abandoned is skipped rather than + /// answered: after a restart its reply channel is already closed, and + /// replying would report a protocol error for a barrier nobody is waiting + /// on. + async fn next(&mut self) -> Result { + loop { + let received = self.receive().await?; + if matches!(&received, Received::Interaction(interaction) if interaction.is_expired()) { + continue; + } + return Ok(received); + } + } + + /// Take the next thing to happen to this call, whoever submitted it. + async fn receive(&mut self) -> Result { + if matches!(self.execution, ToolExecution::Agent { .. }) { + return self.receive_agent().await; + } + let task = self.task.as_mut().ok_or(ExecutorError::HostDisconnected)?; + tokio::select! { + request = self.receiver.recv() => { + let request = request.ok_or(ExecutorError::HostDisconnected)?; + self.invocation = Some(request.call.id); + Ok(Received::Interaction(Box::new(request.interaction))) + } + result = task => { + self.task = None; + self.phase = Phase::Finished; + let result = result?.map_err(|error| ExecutorError::Transport(Box::new(error)))?; + Ok(Received::Finished( + from_mcp(result).map_err(ExecutorError::MalformedResult)?, + )) + } + } + } + + /// Wait on an agent-submitted call, which JP holds no request future for. + /// + /// Before the first interaction the call has no service-side name, so the + /// only thing bounding the wait is the clock: the agent may never submit + /// what it announced. + /// Afterwards the service's own cancellation token stands in for the task a + /// Host-submitted call would have been selecting on. + async fn receive_agent(&mut self) -> Result { + let request = match self.invocation { + Some(id) => match self.service.call_cancellation(id) { + Some(token) => tokio::select! { + biased; + () = token.cancelled() => Err(ExecutorError::Cancelled), + request = self.receiver.recv() => { + request.ok_or(ExecutorError::HostDisconnected) + } + }, + // The invocation has left the active set, so nothing further + // is coming for it. + None => Err(ExecutorError::Cancelled), + }, + None => match timeout(AGENT_CALL_TIMEOUT, self.receiver.recv()).await { + Ok(request) => request.ok_or(ExecutorError::HostDisconnected), + Err(_) => Err(ExecutorError::ExternalCallTimeout), + }, + }; + + let request = match request { + Ok(request) => request, + Err(error) => { + self.phase = Phase::Finished; + return Err(error); + } + }; + self.invocation = Some(request.call.id); + Ok(Received::Interaction(Box::new(request.interaction))) + } +} + +/// The result the service should deliver, given what the Host recorded. +/// +/// An unedited review returns the result the service offered, so resources, +/// annotations, structured content, and error details survive a review that +/// changed nothing. +/// Anything else is rebuilt from the recorded text, which is all the Host's +/// replacement content amounts to. +fn approved(offered: Option, review: &Review) -> ToolResult { + match offered { + Some(offered) if !review.edited => offered, + _ => ToolResult::from(review.response.result.clone()), + } +} + +impl CallSlot { + /// Release whichever barrier the call is parked on with the Host's final + /// content, then drain the call to its MCP response. + async fn acknowledge(&self, review: &Review) -> Result<(), ExecutorError> { + let mut state = self.state.lock().await; + if self.held.swap(false, Ordering::AcqRel) { + return self.deliver_held(&mut state, review).await; + } + let recorded = matches!(state.phase, Phase::Record(_)); + match mem::replace(&mut state.phase, Phase::Finished) { + // Nothing is outstanding: the call already delivered its result, or + // never started because preparation failed. + Phase::Idle | Phase::Finished => return Ok(()), + Phase::Admission(reply) => drop(reply.send(Ok(Admission::Complete { + result: approved(None, review), + }))), + Phase::Release(reply) => drop(reply.send(Ok(ReleaseDecision::Complete { + result: approved(None, review), + }))), + Phase::Input { reply, .. } => drop(reply.send(Ok(InputAnswer::Complete { + result: approved(None, review), + }))), + Phase::Review { offered, reply } => { + drop(reply.send(Ok(approved(Some(offered), review)))); + } + Phase::Record(reply) => drop(reply.send(Ok(()))), + } + if recorded && state.submitted_elsewhere() { + return Ok(()); + } + self.drain(&mut state, review).await + } + + /// Hand a held call the content the Host recorded for it. + /// + /// The attempt already stopped, so no barrier is outstanding: the service + /// delivers this content as the call's MCP response. + async fn deliver_held( + &self, + state: &mut PendingCall, + review: &Review, + ) -> Result<(), ExecutorError> { + state.phase = Phase::Finished; + let Some(id) = *locked(&self.invocation) else { + return Ok(()); + }; + // `false` means the call finished on its own first; its caller already + // has that result. + if !state.service.complete_call(id, approved(None, review)) { + return Ok(()); + } + if state.submitted_elsewhere() { + return Ok(()); + } + self.drain(state, review).await + } + + /// Answer the service's remaining barriers and check what it delivered. + /// + /// A call JP submitted ends at its MCP response, which is checked against + /// what the Host recorded. + /// A call an agent submitted has no response to wait for here — it goes to + /// the agent — so recording is the last barrier and the drain ends there. + async fn drain(&self, state: &mut PendingCall, review: &Review) -> Result<(), ExecutorError> { + loop { + match state.next().await? { + Received::Interaction(interaction) => match *interaction { + // The Host settled this call at an earlier barrier, so the + // content it recorded is also what it approves here. + Interaction::Review { result, reply, .. } => { + drop(reply.send(Ok(approved(Some(result), review)))); + } + Interaction::Record { reply, .. } => { + drop(reply.send(Ok(()))); + if state.submitted_elsewhere() { + state.phase = Phase::Finished; + return Ok(()); + } + } + _ => { + return Err(ExecutorError::UnexpectedInteraction { phase: "recording" }); + } + }, + Received::Finished(delivered) => { + let delivered = response(&review.response.id, &delivered); + return if delivered.result == review.response.result { + Ok(()) + } else { + Err(ExecutorError::DeliveryMismatch) + }; + } + } + } + } +} + +/// Represents one logical MCP call, including its pending Host interactions. +pub(crate) struct ToolExecutor { + /// The arguments to execute with, which Host editing may replace. + arguments: Map, + config: ToolConfigWithDefaults, + peer: Peer, + service: Arc, + slot: Arc, + formatted: Option, +} + +impl ToolExecutor { + /// Cancel the service-side invocation, if the service has named it yet. + fn cancel_invocation(&self) { + if let Some(id) = *locked(&self.slot.invocation) { + self.service.cancel_call(id); + } + } +} + +#[async_trait] +impl Executor for ToolExecutor { + fn tool_id(&self) -> &str { + &self.slot.request.id + } + + fn tool_name(&self) -> &str { + &self.slot.request.name + } + + fn arguments(&self) -> &Map { + &self.arguments + } + + fn formatted_arguments(&self) -> Option<&Formatted> { + self.formatted.as_ref() + } + + fn needs_permission(&self) -> bool { + !matches!(self.config.run(), RunMode::Unattended | RunMode::Skip) + } + + fn permission_info(&self) -> Option { + let run_mode = self.config.run(); + if matches!(run_mode, RunMode::Unattended | RunMode::Skip) { + return None; + } + Some(PermissionInfo { + tool_id: self.slot.request.id.clone(), + tool_name: self.slot.request.name.clone(), + tool_source: self.config.source().clone(), + run_mode, + arguments: self.arguments.clone().into(), + }) + } + + fn set_arguments(&mut self, args: Value) { + if let Value::Object(arguments) = args { + self.arguments = arguments; + } + } + + fn pause_for_restart(&self) -> bool { + let Some(id) = *locked(&self.slot.invocation) else { + // Nothing to pause: the service has not named this call yet, so a + // restart has to submit it afresh. + return false; + }; + // Set before asking, so the cancellation that follows sees a paused + // call rather than one to tear down. + self.slot.restarting.store(true, Ordering::Release); + if self.service.pause_call(id) { + return true; + } + self.slot.restarting.store(false, Ordering::Release); + false + } + + fn hold_for_response(&self) -> bool { + let Some(id) = *locked(&self.slot.invocation) else { + return false; + }; + // Set before pausing, so the cancellation that follows sees a held + // call rather than one to tear down. + self.slot.held.store(true, Ordering::Release); + if self.service.pause_call(id) { + return true; + } + self.slot.held.store(false, Ordering::Release); + false + } + + async fn prepare( + &mut self, + render_arguments: bool, + ) -> Result, ExecutorError> { + let mut state = self.slot.state.lock().await; + let restarting = self.slot.restarting.swap(false, Ordering::AcqRel); + + if restarting { + // The paused attempt left the call parked on a barrier the service + // has since abandoned. Drop it and ask the service for a fresh + // preparation cycle on the same invocation. + state.phase = Phase::Idle; + let id = state.invocation.ok_or(ExecutorError::OutOfOrder { + operation: "be restarted", + phase: "not yet submitted", + })?; + self.service.resume_call(id); + } else if !matches!(state.phase, Phase::Idle) { + return Err(ExecutorError::OutOfOrder { + operation: "be submitted", + phase: state.phase.name(), + }); + } + + // A restart reuses the request already in flight, and an agent submits + // its own; only a first Host-submitted call issues one here. + if !restarting && state.execution == ToolExecution::Caller { + let mut params = CallToolRequestParams::new(self.slot.request.name.clone()); + params.arguments = Some(self.arguments.clone()); + params.meta = Some(Meta(Map::from_iter([( + CORRELATION_KEY.into(), + self.slot.key.0.clone().into(), + )]))); + let peer = self.peer.clone(); + state.task = Some(tokio::spawn(async move { peer.call_tool(params).await })); + } + loop { + match state.next().await? { + Received::Interaction(interaction) => match *interaction { + Interaction::RenderArguments { reply } => { + drop(reply.send(Ok(render_arguments))); + } + Interaction::Prepare { + arguments, + formatted_arguments, + reply, + .. + } => { + self.arguments = arguments; + self.formatted = formatted_arguments; + state.phase = Phase::Admission(reply); + // Remember the service's name for this call while a + // reply is in hand: a later restart has to resume this + // invocation rather than start another. + if let Some(id) = state.invocation { + *locked(&self.slot.invocation) = Some(id); + } + return Ok(None); + } + Interaction::Record { recording, reply } => { + let response = response(&self.slot.request.id, &recording.result); + state.phase = Phase::Record(reply); + return Ok(Some(response)); + } + _ => { + return Err(ExecutorError::UnexpectedInteraction { phase: "preparing" }); + } + }, + Received::Finished(result) => { + return Ok(Some(response(&self.slot.request.id, &result))); + } + } + } + } + + async fn approve(&mut self) -> Result<(), ExecutorError> { + let mut state = self.slot.state.lock().await; + let Phase::Admission(reply) = mem::replace(&mut state.phase, Phase::Finished) else { + state.phase = Phase::Finished; + return Err(ExecutorError::OutOfOrder { + operation: "be approved", + phase: "not awaiting admission", + }); + }; + reply + .send(Ok(Admission::Run { + arguments: self.arguments.clone(), + })) + .map_err(|_| ExecutorError::ReplyExpired { + operation: "approval", + })?; + match state.next().await? { + Received::Interaction(interaction) => match *interaction { + Interaction::Release { + arguments, + formatted_arguments, + reply, + } => { + self.arguments = arguments; + self.formatted = formatted_arguments; + state.phase = Phase::Release(reply); + Ok(()) + } + _ => Err(ExecutorError::UnexpectedInteraction { phase: "approving" }), + }, + // Validating the approved arguments can fail the call outright, + // which arrives as the MCP response rather than another barrier. + Received::Finished(result) if result.is_error() => Err(ExecutorError::Rejected { + message: result.to_text(), + }), + Received::Finished(_) => { + Err(ExecutorError::UnexpectedInteraction { phase: "approving" }) + } + } + } + + async fn execute( + &self, + answers: &IndexMap, + cancellation: CancellationToken, + stderr: Option, + ) -> ExecutorResult { + let mut state = self.slot.state.lock().await; + *locked(&self.slot.stderr) = stderr; + let attempt = async { + match mem::replace(&mut state.phase, Phase::Finished) { + Phase::Release(reply) => { + reply.send(Ok(ReleaseDecision::Execute)).map_err(|_| { + ExecutorError::ReplyExpired { + operation: "release", + } + })?; + } + Phase::Input { id, reply } => { + let answer = answers + .get(id.as_str()) + .ok_or(ExecutorError::MissingAnswer)? + .clone(); + reply.send(Ok(InputAnswer::Answer(answer))).map_err(|_| { + ExecutorError::ReplyExpired { + operation: "inquiry", + } + })?; + } + phase => { + let name = phase.name(); + state.phase = phase; + return Err(ExecutorError::OutOfOrder { + operation: "execute", + phase: name, + }); + } + } + let id = &self.slot.request.id; + match state.next().await? { + Received::Interaction(interaction) => match *interaction { + Interaction::Input { + request, + supporting, + answers, + reply, + } => { + let question = question(request, &supporting); + state.phase = Phase::Input { + id: question.id.clone(), + reply, + }; + Ok(ExecutorResult::NeedsInput { + tool_id: id.clone(), + tool_name: self.slot.request.name.clone(), + source: InquirySource::tool(&self.slot.request.name), + question, + accumulated_answers: answers, + }) + } + Interaction::Review { result, reply, .. } => { + let offered = response(id, &result); + state.phase = Phase::Review { + offered: result, + reply, + }; + Ok(ExecutorResult::Completed(offered)) + } + Interaction::Record { recording, reply } => { + let response = response(id, &recording.result); + state.phase = Phase::Record(reply); + Ok(ExecutorResult::Completed(response)) + } + _ => Err(ExecutorError::UnexpectedInteraction { phase: "executing" }), + }, + Received::Finished(result) => Ok(ExecutorResult::Completed(response(id, &result))), + } + }; + let result = tokio::select! { + biased; + () = cancellation.cancelled() => Err(ExecutorError::Cancelled), + result = attempt => result, + }; + // The service reports an outcome only once the attempt's process has + // exited and its stderr has been drained, so the sink has nothing left + // to receive. A question's next attempt brings its own. + *locked(&self.slot.stderr) = None; + result.unwrap_or_else(|error| { + // A paused call is meant to come back, and a held one waits for the + // Host's response, so either way its invocation stays alive and the + // cancellation that ended this attempt is reported without tearing + // the service-side call down. + if self.slot.restarting.load(Ordering::Acquire) + || self.slot.held.load(Ordering::Acquire) + { + return ExecutorResult::Completed(ToolCallResponse { + id: self.slot.request.id.clone(), + result: Err(error.to_string()), + }); + } + // Anything else cannot continue, so stop the service-side work + // rather than leaving it parked on a reply that never arrives. + self.cancel_invocation(); + state.phase = Phase::Finished; + if error.is_call_outcome() { + return ExecutorResult::Completed(ToolCallResponse { + id: self.slot.request.id.clone(), + result: Err(error.to_string()), + }); + } + ExecutorResult::Failed(error) + }) + } +} + +/// Render a shared input request as the question the terminal prompts with. +/// +/// The supporting blocks are the content the tool emitted before its request; +/// the terminal shows them above the prompt. +fn question(request: InputRequest, supporting: &[ContentBlock]) -> Question { + let preamble = supporting + .iter() + .filter_map(ContentBlock::as_text) + .collect::>() + .join("\n\n"); + let mut question = Question::new(request.id, request.label, request.answer_type); + question.pre_amble = (!preamble.is_empty()).then_some(preamble); + question.default = request.default; + question +} + +#[cfg(test)] +#[path = "mcp_executor_tests.rs"] +mod tests; diff --git a/crates/jp_cli/src/cmd/query/tool/mcp_executor_shutdown_tests.rs b/crates/jp_cli/src/cmd/query/tool/mcp_executor_shutdown_tests.rs new file mode 100644 index 000000000..bd47cf4cc --- /dev/null +++ b/crates/jp_cli/src/cmd/query/tool/mcp_executor_shutdown_tests.rs @@ -0,0 +1,158 @@ +//! What a client is still allowed to do while the endpoint is shutting down. + +use reqwest::{Client as HttpClient, Error as HttpError, Response}; +use rmcp::{ + ServerHandler, ServiceExt as _, + model::{ClientJsonRpcMessage, ServerJsonRpcMessage}, + transport::{Transport, async_rw::AsyncRwTransport}, +}; +use serde_json::json; +use tokio::{ + io, + sync::oneshot, + time::{Duration, timeout}, +}; + +use super::{Fixture, RoleClient}; + +/// An MCP server with no tools, standing in for the one a real client talks to. +struct EmptyServer; +impl ServerHandler for EmptyServer {} + +/// Reports what a session-closing DELETE actually returned. +/// +/// rmcp logs an HTTP deletion failure and returns `Ok` from `cancel()` anyway, +/// so a test watching only the return value cannot tell a served DELETE from a +/// refused one. +struct DeleteOnClose { + inner: T, + url: String, + session: String, + result: Option>>, +} + +impl> Transport for DeleteOnClose { + type Error = T::Error; + + fn send( + &mut self, + message: ClientJsonRpcMessage, + ) -> impl Future> + Send + 'static { + self.inner.send(message) + } + + async fn receive(&mut self) -> Option { + self.inner.receive().await + } + + async fn close(&mut self) -> Result<(), Self::Error> { + let result = HttpClient::builder() + .no_proxy() + .build() + .expect("an HTTP client with no proxy") + .delete(&self.url) + .header("mcp-session-id", &self.session) + .header("mcp-protocol-version", "2025-11-25") + .send() + .await + .and_then(Response::error_for_status) + .map(drop); + if let Some(sender) = self.result.take() { + drop(sender.send(result)); + } + + self.inner.close().await + } +} + +/// Shutdown closes the MCP session before it stops the listener, and the DELETE +/// that closes it is an HTTP request the listener has to still be serving. +/// +/// Stopping the listener first would leave the session open on a server that +/// can no longer hear about it. +#[tokio::test] +async fn a_client_can_close_its_session_before_the_listener_stops() { + timeout(Duration::from_secs(5), async { + let mut fixture = Fixture::inquiring("unattended").await; + + // Retire the fixture's own connection, leaving the endpoint running so + // the session below is the only one outstanding. + fixture + .owner + .client + .take() + .expect("the fixture connected a client") + .cancel() + .await + .unwrap(); + let url = fixture + .owner + .endpoint + .as_ref() + .expect("the fixture started an endpoint") + .url() + .to_owned(); + + let initialized = HttpClient::builder() + .no_proxy() + .build() + .unwrap() + .post(&url) + .header("accept", "application/json, text/event-stream") + .json(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "shutdown-test", "version": "1"}, + }, + })) + .send() + .await + .unwrap() + .error_for_status() + .unwrap(); + let session = initialized.headers()["mcp-session-id"] + .to_str() + .unwrap() + .to_owned(); + initialized.bytes().await.unwrap(); + + // The client's own transport is a local pipe: only the DELETE it sends + // on close has to reach the endpoint. + let (client_io, server_io) = io::duplex(4096); + let remote = tokio::spawn(async { + EmptyServer + .serve(server_io) + .await + .unwrap() + .waiting() + .await + .unwrap() + }); + let (read, write) = io::split(client_io); + let (result, deleted) = oneshot::channel(); + fixture.owner.client = Some( + ().serve(DeleteOnClose { + inner: AsyncRwTransport::new_client(read, write), + url, + session, + result: Some(result), + }) + .await + .unwrap(), + ); + + fixture.owner.shutdown().await.unwrap(); + + deleted + .await + .expect("the transport reported its DELETE") + .expect("the listener served the session DELETE"); + remote.await.unwrap(); + }) + .await + .unwrap(); +} diff --git a/crates/jp_cli/src/cmd/query/tool/mcp_executor_tests.rs b/crates/jp_cli/src/cmd/query/tool/mcp_executor_tests.rs new file mode 100644 index 000000000..1b84a673f --- /dev/null +++ b/crates/jp_cli/src/cmd/query/tool/mcp_executor_tests.rs @@ -0,0 +1,583 @@ +use std::{ + future::pending, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, +}; + +use async_trait::async_trait; +use jp_config::{ + AppConfig, Config as _, + conversation::tool::{PartialToolConfig, ToolConfig}, +}; +use jp_conversation::event::ToolCallResponse; +use jp_mcp::server::{BuiltinTool, result::to_mcp}; +use jp_tool::{ + ContentBlock, Outcome, ToolDocs, ToolResult, + content::{ErrorDetails, Resource, ToolStatus}, +}; +use rmcp::model::CallToolRequestParams; +use serde_json::json; +use tokio::time::{Duration, timeout}; + +use super::*; + +/// A tool that asks one question, then echoes the arguments and the answer. +/// +/// The counter is how a test tells "the tool never ran" from "the tool ran and +/// its output went nowhere": every assertion about a denied or cancelled call +/// pairs the outcome with a count. +struct InquiringTool(Arc); + +#[async_trait] +impl BuiltinTool for InquiringTool { + async fn execute(&self, arguments: &Value, answers: &IndexMap) -> Outcome { + self.0.fetch_add(1, Ordering::SeqCst); + if let Some(answer) = answers.get("confirm") { + return Outcome::Success { + content: json!({"arguments": arguments, "answer": answer}).to_string(), + }; + } + Question::boolean("confirm", "Continue?").unwrap().into() + } +} + +/// A tool that runs until its attempt is abandoned, so an interrupt always +/// lands while it is still in flight. +struct BlockingTool(Arc); + +#[async_trait] +impl BuiltinTool for BlockingTool { + async fn execute(&self, _: &Value, _: &IndexMap) -> Outcome { + self.0.fetch_add(1, Ordering::SeqCst); + pending().await + } +} + +struct Fixture { + source: TerminalExecutorSource, + owner: ExecutionOwner, + config: ToolConfigWithDefaults, + count: Arc, +} + +impl Fixture { + /// Start a service exposing one `example` tool with the given config. + async fn start(config: Value, tool: impl BuiltinTool + 'static) -> Self { + let partial: PartialToolConfig = serde_json::from_value(config).unwrap(); + let mut cfg = AppConfig::new_test(); + cfg.conversation.tools.insert( + "example".into(), + ToolConfig::from_partial(partial, vec![]).unwrap(), + ); + let count = Arc::new(AtomicUsize::new(0)); + let definitions = vec![ToolDefinition { + name: "example".into(), + docs: ToolDocs::default(), + parameters: json!({ + "type": "object", + "properties": {"name": {"type": "string"}}, + }), + }]; + let (source, owner) = TerminalExecutorSource::start( + BuiltinExecutors::new().register("example", tool), + &definitions, + &cfg.conversation.tools, + Arc::new(ApprovalStore::default()), + InvocationContext::default(), + &Client::default(), + "/tmp".into(), + ) + .await + .unwrap(); + Self { + source, + owner, + config: cfg.conversation.tools.get("example").unwrap(), + count, + } + } + + /// Start a fixture whose tool asks a question before completing. + async fn inquiring(result_mode: &str) -> Self { + let count = Arc::new(AtomicUsize::new(0)); + let tool = InquiringTool(count.clone()); + let mut fixture = Self::start( + json!({"source": "builtin", "run": "ask", "result": result_mode}), + tool, + ) + .await; + fixture.count = count; + fixture + } + + fn executor(&self, arguments: &Value) -> Box { + self.source + .create( + ToolCallRequest { + id: "call-1".into(), + name: "example".into(), + arguments: arguments.as_object().cloned().unwrap_or_default(), + }, + self.config.clone(), + ) + .unwrap() + } + + fn attempts(&self) -> usize { + self.count.load(Ordering::SeqCst) + } + + /// Acknowledge a call, failing rather than hanging if the service never + /// releases its barrier. + async fn acknowledge(&self, review: Review) -> Result<(), ExecutorError> { + timeout(Duration::from_secs(5), self.source.acknowledge(review)) + .await + .expect("acknowledgement timed out") + } + + async fn shutdown(self) { + self.owner.shutdown().await.unwrap(); + } +} + +fn recorded(result: Result<&str, &str>) -> Review { + Review::replaced(ToolCallResponse { + id: "call-1".into(), + result: result.map(str::to_owned).map_err(str::to_owned), + }) +} + +#[path = "mcp_executor_shutdown_tests.rs"] +mod shutdown; + +#[tokio::test] +async fn one_call_spans_input_and_recording() { + let fixture = Fixture::inquiring("edit").await; + let mut executor = fixture.executor(&json!({"name": "original"})); + + assert!(executor.prepare(false).await.unwrap().is_none()); + assert_eq!(fixture.attempts(), 0); + + executor.set_arguments(json!({"name": "edited"})); + executor.approve().await.unwrap(); + assert_eq!(fixture.attempts(), 0, "approval alone must not execute"); + + let first = executor + .execute(&IndexMap::new(), CancellationToken::new(), None) + .await; + let ExecutorResult::NeedsInput { question, .. } = first else { + panic!("expected the tool's question, got {first:?}") + }; + assert_eq!(question, Question::boolean("confirm", "Continue?").unwrap()); + assert_eq!(fixture.attempts(), 1); + + let second = executor + .execute( + &IndexMap::from_iter([("confirm".into(), json!(true))]), + CancellationToken::new(), + None, + ) + .await; + let ExecutorResult::Completed(response) = second else { + panic!("expected a completed call, got {second:?}") + }; + // The answer reached a second execution of the same logical call, and the + // arguments it ran with are the edited ones. + assert_eq!( + response.result, + Ok(r#"{"arguments":{"name":"edited"},"answer":true}"#.into()) + ); + assert_eq!(fixture.attempts(), 2); + + fixture.acknowledge(recorded(Ok("reviewed"))).await.unwrap(); + assert_eq!(fixture.attempts(), 2, "acknowledgement must not re-execute"); + fixture.shutdown().await; +} + +/// A result carrying everything the conversation's text projection drops. +fn rich_result() -> ToolResult { + ToolResult { + content: vec![ + ContentBlock::text("plain text"), + ContentBlock::Resource(Resource::text("file:///a", "embedded")), + ], + status: ToolStatus::Error(ErrorDetails { + transient: true, + trace: vec!["upstream".into()], + }), + structured_content: Some(json!({"answer": 42})), + metadata: None, + } +} + +#[test] +fn an_unedited_review_delivers_the_result_the_service_offered() { + let offered = rich_result(); + let recorded = response("call-1", &offered); + // The conversation keeps only the text, and it is an error, so a result + // rebuilt from it would be a single text block with no resource, no + // structured content, and default error details. + assert_eq!( + recorded.result, + Err("plain text\n\nembedded".into()), + "the text projection is what the conversation records" + ); + + let delivered = approved(Some(offered.clone()), &Review::unchanged(recorded)); + + assert_eq!(delivered, offered); +} + +#[test] +fn an_edited_review_delivers_the_content_the_host_recorded() { + let offered = rich_result(); + let edited = Review::replaced(ToolCallResponse { + id: "call-1".into(), + result: Ok("the user rewrote this".into()), + }); + + let delivered = approved(Some(offered), &edited); + + // Editing replaces the content outright: the caller must not receive the + // resource, structured content, or error status of a result the Host chose + // not to deliver. + assert_eq!(delivered, ToolResult::text("the user rewrote this")); +} + +#[test] +fn a_barrier_with_no_result_behind_it_delivers_the_recorded_content() { + // A call the Host denied before execution has no result of its own, so + // there is nothing to preserve and the recorded text is all there is. + let denied = Review::unchanged(ToolCallResponse { + id: "call-1".into(), + result: Err("not approved".into()), + }); + + assert_eq!(approved(None, &denied), ToolResult::error("not approved")); +} + +#[tokio::test] +async fn an_unedited_review_reaches_the_service_through_a_real_call() { + // The unit tests above pin the decision; this pins that a review actually + // reaches it, rather than the call resolving at some earlier barrier. + let fixture = Fixture::inquiring("ask").await; + let mut executor = fixture.executor(&json!({})); + assert!(executor.prepare(false).await.unwrap().is_none()); + executor.approve().await.unwrap(); + + let first = executor + .execute(&IndexMap::new(), CancellationToken::new(), None) + .await; + assert!(matches!(first, ExecutorResult::NeedsInput { .. })); + + let second = executor + .execute( + &IndexMap::from_iter([("confirm".into(), json!(true))]), + CancellationToken::new(), + None, + ) + .await; + let ExecutorResult::Completed(response) = second else { + panic!("expected a reviewable result, got {second:?}") + }; + + // Recording the offered content unchanged completes the call: the service + // accepts its own result back and returns it to the caller. + fixture + .acknowledge(Review::unchanged(response)) + .await + .unwrap(); + assert_eq!(fixture.attempts(), 2); + fixture.shutdown().await; +} + +#[tokio::test] +async fn a_denied_call_completes_without_executing() { + let fixture = Fixture::inquiring("unattended").await; + let mut executor = fixture.executor(&json!({})); + assert!(executor.prepare(false).await.unwrap().is_none()); + + fixture + .acknowledge(recorded(Ok("not approved"))) + .await + .unwrap(); + + assert_eq!(fixture.attempts(), 0, "a denied call must not run the tool"); + // Acknowledging again is a no-op rather than an error: the call is gone. + fixture + .acknowledge(recorded(Ok("not approved"))) + .await + .unwrap(); + fixture.shutdown().await; +} + +#[tokio::test] +async fn a_failure_after_approval_resolves_the_call() { + let fixture = Fixture::inquiring("skip").await; + let mut executor = fixture.executor(&json!({})); + executor.prepare(false).await.unwrap(); + executor.approve().await.unwrap(); + + // The Host abandons the call at the release barrier rather than executing. + fixture + .acknowledge(recorded(Err("formatter failed"))) + .await + .unwrap(); + + assert_eq!(fixture.attempts(), 0); + fixture.shutdown().await; +} + +#[tokio::test] +async fn a_declined_inquiry_finishes_without_another_attempt() { + let fixture = Fixture::inquiring("skip").await; + let mut executor = fixture.executor(&json!({})); + executor.prepare(false).await.unwrap(); + executor.approve().await.unwrap(); + + let result = executor + .execute(&IndexMap::new(), CancellationToken::new(), None) + .await; + assert!(matches!(result, ExecutorResult::NeedsInput { .. })); + assert_eq!(fixture.attempts(), 1); + + fixture + .acknowledge(recorded(Ok("question declined"))) + .await + .unwrap(); + + assert_eq!( + fixture.attempts(), + 1, + "declining the question must not run the tool again" + ); + fixture.shutdown().await; +} + +#[tokio::test] +async fn cancellation_before_release_does_not_execute() { + let fixture = Fixture::inquiring("unattended").await; + let mut executor = fixture.executor(&json!({})); + executor.prepare(false).await.unwrap(); + executor.approve().await.unwrap(); + + let token = CancellationToken::new(); + token.cancel(); + let result = executor.execute(&IndexMap::new(), token, None).await; + + let ExecutorResult::Completed(response) = result else { + panic!("expected a cancelled response, got {result:?}") + }; + assert_eq!(response.result, Err("Tool execution cancelled.".into())); + assert_eq!(fixture.attempts(), 0); + + fixture + .acknowledge(Review::unchanged(response)) + .await + .unwrap(); + fixture.shutdown().await; +} + +#[tokio::test] +async fn a_held_call_delivers_the_recorded_response_to_its_agent() { + // An agent owns the MCP request and builds its own transcript from the + // response, so the text the Host recorded for a cancelled call is only seen + // by the model if it is what the agent receives. + let count = Arc::new(AtomicUsize::new(0)); + let mut fixture = Fixture::start( + json!({"source": "builtin", "run": "unattended"}), + BlockingTool(count.clone()), + ) + .await; + fixture.count = count; + fixture + .source + .set_execution(ToolExecution::Agent { + correlation_key: "test/agentId", + }) + .unwrap(); + let mut executor = fixture.executor(&json!({})); + + let mut params = CallToolRequestParams::new("example"); + params.arguments = Some(Map::new()); + params.meta = Some(Meta(Map::from_iter([( + "test/agentId".into(), + "call-1".into(), + )]))); + let peer = fixture.source.peer.clone(); + let agent = tokio::spawn(async move { peer.call_tool(params).await }); + + assert!(executor.prepare(false).await.unwrap().is_none()); + executor.approve().await.unwrap(); + + let token = CancellationToken::new(); + let answers = IndexMap::new(); + let running = executor.execute(&answers, token.clone(), None); + tokio::pin!(running); + let started = async { + while fixture.attempts() == 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }; + timeout(Duration::from_secs(5), async { + tokio::select! { + result = &mut running => panic!("the tool must still be running, got {result:?}"), + () = started => {} + } + }) + .await + .expect("the tool never started"); + + assert!(executor.hold_for_response(), "a named call can be held"); + token.cancel(); + let result = running.await; + assert!( + matches!(result, ExecutorResult::Completed(_)), + "a held call reports its attempt as over, got {result:?}" + ); + + fixture + .acknowledge(recorded(Ok( + "Tool run cancelled by user with a custom message:\n\nuse grep instead" + ))) + .await + .unwrap(); + + let delivered = timeout(Duration::from_secs(5), agent) + .await + .expect("the agent's call must finish") + .unwrap() + .map(|result| serde_json::to_value(result).unwrap()) + .map_err(|error| error.to_string()); + assert_eq!( + delivered, + Ok(json!({ + "content": [{ + "type": "text", + "text": "Tool run cancelled by user with a custom message:\n\nuse grep instead", + }], + "isError": false, + })) + ); + assert_eq!(fixture.attempts(), 1, "holding must not run the tool again"); + fixture.shutdown().await; +} + +#[tokio::test] +async fn a_protocol_failure_is_reported_as_a_failure_not_as_tool_output() { + // Executing before the call is released puts the adapter and the service + // out of step. That is JP's problem, so it must not arrive as a tool + // result the model reads as "the tool said this". + let fixture = Fixture::inquiring("unattended").await; + let executor = fixture.executor(&json!({})); + + let result = executor + .execute(&IndexMap::new(), CancellationToken::new(), None) + .await; + + let ExecutorResult::Failed(error) = result else { + panic!("a protocol failure must not be a tool result, got {result:?}") + }; + assert_eq!( + error.to_string(), + "MCP call cannot execute while not yet submitted" + ); + assert_eq!(fixture.attempts(), 0); + fixture.shutdown().await; +} + +#[tokio::test] +async fn preparing_a_call_twice_is_refused() { + let fixture = Fixture::inquiring("unattended").await; + let mut executor = fixture.executor(&json!({})); + executor.prepare(false).await.unwrap(); + + let error = executor.prepare(false).await.unwrap_err(); + assert_eq!( + error.to_string(), + "MCP call cannot be submitted while awaiting admission" + ); + assert_eq!(fixture.attempts(), 0); + fixture.shutdown().await; +} + +#[tokio::test] +async fn approval_is_refused_before_the_call_is_submitted() { + let fixture = Fixture::inquiring("unattended").await; + let mut executor = fixture.executor(&json!({})); + + let error = executor.approve().await.unwrap_err(); + assert_eq!( + error.to_string(), + "MCP call cannot be approved while not awaiting admission" + ); + assert_eq!(fixture.attempts(), 0); + fixture.shutdown().await; +} + +#[tokio::test] +async fn caller_metadata_cannot_claim_another_call() { + // The correlation key is the Host's own, so an MCP call that arrives + // without it (or with the wrong one) never reaches a Host route and fails + // closed rather than borrowing another call's approval. + let fixture = Fixture::inquiring("unattended").await; + let _executor = fixture.executor(&json!({})); + + let mut params = CallToolRequestParams::new("example"); + params.arguments = Some(Map::new()); + params.meta = Some(Meta( + json!({"computer.jp/hostCall": "0".repeat(32)}) + .as_object() + .unwrap() + .clone(), + )); + let peer = fixture.source.peer.clone(); + let call = tokio::spawn(async move { peer.call_tool(params).await }); + + // No Host route accepts the forged key, so the service's interaction is + // dropped and the call ends without an admission decision. + let outcome = timeout(Duration::from_secs(5), call) + .await + .expect("forged call must not hang") + .unwrap(); + assert!( + outcome.is_err(), + "a forged correlation key must not execute" + ); + assert_eq!(fixture.attempts(), 0); + fixture.shutdown().await; +} + +#[test] +fn a_rich_result_projects_to_text_and_survives_the_mcp_round_trip() { + // Pins the compatibility projection the conversation stores against the + // result the caller receives: the first drops everything but text, the + // second keeps all of it. + let result = ToolResult { + content: vec![ + ContentBlock::text("plain text"), + ContentBlock::Resource(Resource::text("file:///a", "embedded")), + ], + status: ToolStatus::Success, + structured_content: Some(json!({"answer": 42})), + metadata: None, + }; + + assert_eq!( + response("call-1", &result).result, + Ok("plain text\n\nembedded".into()) + ); + assert_eq!( + serde_json::to_value(to_mcp(result).unwrap()).unwrap(), + json!({ + "content": [ + {"type": "text", "text": "plain text"}, + {"type": "resource", "resource": {"uri": "file:///a", "text": "embedded"}}, + ], + "structuredContent": {"answer": 42}, + "isError": false, + }) + ); +} diff --git a/crates/jp_cli/src/cmd/query/tool/pending.rs b/crates/jp_cli/src/cmd/query/tool/pending.rs index 28278ac72..9b30a0028 100644 --- a/crates/jp_cli/src/cmd/query/tool/pending.rs +++ b/crates/jp_cli/src/cmd/query/tool/pending.rs @@ -24,7 +24,8 @@ use jp_conversation::{ ConversationStream, event::{ToolCallRequest, ToolCallResponse}, }; -use jp_llm::tool::executor::Executor; + +use super::executor::Executor; /// The work product for a single tool call, as decided during the streaming /// phase. diff --git a/crates/jp_cli/src/cmd/query/tool/pending_tests.rs b/crates/jp_cli/src/cmd/query/tool/pending_tests.rs index 2ae352082..586023f76 100644 --- a/crates/jp_cli/src/cmd/query/tool/pending_tests.rs +++ b/crates/jp_cli/src/cmd/query/tool/pending_tests.rs @@ -2,10 +2,10 @@ use jp_conversation::{ ConversationStream, event::{ChatRequest, ChatResponse, ToolCallRequest, ToolCallResponse}, }; -use jp_llm::tool::executor::MockExecutor; use serde_json::Map; use super::*; +use crate::cmd::query::tool::executor::mock::MockExecutor; fn req(id: &str, name: &str) -> ToolCallRequest { ToolCallRequest { diff --git a/crates/jp_cli/src/cmd/query/tool/prompter.rs b/crates/jp_cli/src/cmd/query/tool/prompter.rs index 229c17b50..a29ab1727 100644 --- a/crates/jp_cli/src/cmd/query/tool/prompter.rs +++ b/crates/jp_cli/src/cmd/query/tool/prompter.rs @@ -21,12 +21,12 @@ use jp_config::conversation::tool::{RunMode, ToolSource}; use jp_conversation::event::SelectOption; use jp_editor::{EditOutcome, EditorBackend}; use jp_inquire::{InlineOption, ReplyEditMode, ReplyOutcome, prompt::PromptBackend}; -use jp_llm::tool::executor::PermissionInfo; use jp_printer::{Printer, PromptWriter}; use jp_term::{background::DefaultBackground, shade::ShadedWriter}; use jp_tool::AnswerType; use serde_json::Value; +use super::executor::PermissionInfo; use crate::{Error, editor::report_editor_failure}; /// Result of a permission prompt. diff --git a/crates/jp_cli/src/cmd/query/turn/coordinator.rs b/crates/jp_cli/src/cmd/query/turn/coordinator.rs index 1789e251b..e69ce1306 100644 --- a/crates/jp_cli/src/cmd/query/turn/coordinator.rs +++ b/crates/jp_cli/src/cmd/query/turn/coordinator.rs @@ -373,9 +373,12 @@ impl TurnCoordinator { HandleEventOutcome::new(self.transition_from_streaming(stream, reason)) } - // Patch is handled by the caller before reaching here; KeepAlive is - // a liveness signal with nothing to record or render. - Event::Patch(_) | Event::KeepAlive => HandleEventOutcome::new(Action::Continue), + // Patches and tool progress are handled by the caller. None of + // these events contributes content to the conversation. + Event::Patch(_) + | Event::KeepAlive + | Event::ToolCallPending { .. } + | Event::ToolCallPendingEnd { .. } => HandleEventOutcome::new(Action::Continue), // A provider decision the user must see (a skipped credential, a // credential switch): chrome on stderr, never part of the diff --git a/crates/jp_cli/src/cmd/query/turn/coordinator_tests.rs b/crates/jp_cli/src/cmd/query/turn/coordinator_tests.rs index 515c5a76e..e15e5fb28 100644 --- a/crates/jp_cli/src/cmd/query/turn/coordinator_tests.rs +++ b/crates/jp_cli/src/cmd/query/turn/coordinator_tests.rs @@ -13,6 +13,35 @@ fn strip_ansi(s: &str) -> String { String::from_utf8(bytes).expect("valid utf-8 after stripping ANSI") } +#[test] +fn pending_tool_progress_does_not_become_an_executable_or_partial_event() { + let mut stream = ConversationStream::new_test(); + let (printer, _, _) = Printer::memory(OutputFormat::Text); + let mut coordinator = TurnCoordinator::new( + Arc::new(printer), + AppConfig::new_test().style, + None, + None, + None, + ); + coordinator.start_turn(&mut stream, ChatRequest::from("test")); + coordinator.handle_event(&mut stream, Event::ToolCallPending { + id: "pending".into(), + name: "fs_create_file".into(), + }); + assert!(coordinator.peek_partial_events().is_empty()); + assert_eq!(coordinator.current_phase(), TurnPhase::Streaming); + let result = coordinator.handle_event(&mut stream, Event::Finished(FinishReason::Completed)); + assert!(matches!(result.action, Action::Done)); + assert_eq!( + stream + .iter() + .filter_map(|event| event.event.as_tool_call_request()) + .count(), + 0 + ); +} + #[test] fn test_transitions_to_executing_on_tool_call() { let mut _turn_state = TurnState::default(); diff --git a/crates/jp_cli/src/cmd/query/turn_loop.rs b/crates/jp_cli/src/cmd/query/turn_loop.rs index 5e677b7fd..a1224c9a5 100644 --- a/crates/jp_cli/src/cmd/query/turn_loop.rs +++ b/crates/jp_cli/src/cmd/query/turn_loop.rs @@ -30,16 +30,16 @@ use jp_conversation::{ }; use jp_inquire::prompt::PromptBackend; use jp_llm::{ - Error as LlmError, Provider, + Error as LlmError, EventStream, Provider, error::StreamError, event::{Event, EventPart, FinishReason, NoticeSink, ToolCallPart}, model::ModelDetails, provider::get_provider, - query::{ChatQuery, Truncation}, - tool::{InvocationContext, ToolDefinition, executor::Executor}, + query::{ChatQuery, QueryContext, ToolExecution, Truncation}, with_idle_timeout, with_output_limit, }; use jp_printer::{ErrChannel, Printer, RegionStyle, StatusRegion}; +use jp_tool::{InvocationContext, ToolDefinition}; use jp_workspace::{ConversationLock, ConversationMut}; use tokio_stream::wrappers::ReceiverStream; use tracing::{debug, info, warn}; @@ -48,7 +48,7 @@ use super::{ PendingStreamTrim, build_sections, build_thread, interrupt::{ LoopAction, StreamingInterruptResult, handle_llm_event, handle_streaming_interrupt, - reply_edit_mode, + reply_edit_mode, signals::InterruptUi, }, stream::{ ResponseBoundary, StreamErrorOutcome, StreamRetryState, commit_partial_response, @@ -57,6 +57,7 @@ use super::{ tool::{ PendingEntry, PendingTools, ToolCallDecision, ToolCallState, ToolCoordinator, ToolPrompter, ToolRenderer, build_execution_plan, + executor::{Executor, Review}, inquiry::{InquiryBackend, InquiryConfig, LlmInquiryBackend}, }, turn::{Action, CommittedEvent, TurnCoordinator, TurnPhase, TurnState}, @@ -175,8 +176,8 @@ pub(super) async fn run_turn_loop( model: &ModelDetails, cfg: &AppConfig, signals: &SignalRouter, - mcp_client: &jp_mcp::Client, root: &Utf8Path, + invocation: InvocationContext, interactive: bool, attachments: &[Attachment], lock: &ConversationLock, @@ -186,7 +187,6 @@ pub(super) async fn run_turn_loop( prompt_backend: Arc, mut tool_coordinator: ToolCoordinator, chat_request: ChatRequest, - invocation: InvocationContext, pending_trim: PendingStreamTrim, mut turn_interrupt: TurnInterrupt, ) -> Result<(), Error> { @@ -215,6 +215,7 @@ pub(super) async fn run_turn_loop( cfg.assistant.name.clone(), Some(cfg.assistant.model.id.resolved().to_string()), ); + let query_invocation = invocation; let mut tool_renderer = ToolRenderer::new( ErrChannel::new(if cfg.style.tool_call.show && !printer.format().is_json() { printer.clone() @@ -222,8 +223,6 @@ pub(super) async fn run_turn_loop( Printer::sink().into() }), cfg.style.clone(), - root.to_path_buf(), - invocation, ); // Share the owed-separator flag so visible assistant content rendered by // the coordinator can cancel a blank line owed by a preceding tool result. @@ -252,6 +251,8 @@ pub(super) async fn run_turn_loop( // Crucially: there's no public way to enumerate this directly — the // stream is the source of truth for "what needs to run." let mut pending_tools = PendingTools::new(); + let mut continuation: Option = None; + let mut execution = ToolExecution::Caller; // Prompter shared between streaming (permission prompts) and // executing (tool question prompts) phases. @@ -340,25 +341,39 @@ pub(super) async fn run_turn_loop( ReceiverStream::new(interrupt_rx).map(StreamingLoopEvent::Interrupt), ); - let raw_stream = provider - .chat_completion_stream(model, query) - .await - .map_err(|e| map_llm_error(e, vec![]))?; - waiting.set_detail("waiting for first tokens"); - let raw_stream = match idle_timeout { - Some(idle) => with_idle_timeout(raw_stream, idle), - None => raw_stream, - }; - // Wrapped outside the provider stream, so the bytes of every - // chained continuation accumulate against a single ceiling - // rather than resetting per link. Bytes the provider discards - // while merging those links are billed but never seen here. - let raw_stream = match output_limit { - Some(max) => with_output_limit(raw_stream, max), - None => raw_stream, + let fresh_request = continuation.is_none(); + let mut raw_stream = if let Some(stream) = continuation.take() { + stream + } else { + let started = provider + .start_query(model, query, QueryContext { + root: root.to_path_buf(), + mcp_endpoint: tool_coordinator.endpoint(), + invocation: Some(query_invocation.clone()), + }) + .await + .map_err(|e| map_llm_error(e, vec![]))?; + execution = started.execution; + tool_coordinator + .set_execution(execution) + .map_err(Error::McpHost)?; + let raw_stream = started.events; + let raw_stream = match idle_timeout { + Some(idle) => with_idle_timeout(raw_stream, idle), + None => raw_stream, + }; + // Wrapped outside the provider stream, so the bytes of every + // chained continuation accumulate against a single ceiling + // rather than resetting per link. Bytes the provider discards + // while merging those links are billed but never seen here. + match output_limit { + Some(max) => with_output_limit(raw_stream, max), + None => raw_stream, + } }; + waiting.set_detail("waiting for first tokens"); let llm_stream = StreamSource::Llm( - raw_stream + (&mut raw_stream) .fuse() .map(|result| StreamingLoopEvent::Llm(Box::new(result))) // Backstop: if the provider stream ends without a @@ -376,7 +391,9 @@ pub(super) async fn run_turn_loop( ))), )))), ); - turn_state.request_count += 1; + if fresh_request { + turn_state.request_count += 1; + } // Reset preparing display for this streaming cycle. tool_renderer.reset(); @@ -460,6 +477,16 @@ pub(super) async fn run_turn_loop( Ok(event) => event, Err(e) => { tool_renderer.cancel_all(); + if matches!(execution, ToolExecution::Agent { .. }) { + commit_partial_response( + &mut turn_coordinator, + &conv, + &printer, + ResponseBoundary::Final, + ); + conv.flush()?; + return Err(LlmError::Stream(e).into()); + } match handle_stream_error( e, @@ -557,6 +584,8 @@ pub(super) async fn run_turn_loop( Event::Flush { .. } | Event::Patch(_) | Event::KeepAlive + | Event::ToolCallPending { .. } + | Event::ToolCallPendingEnd { .. } | Event::Notice(_) => false, }; if !received_provider_event && advances_cycle { @@ -570,7 +599,8 @@ pub(super) async fn run_turn_loop( if let Event::Part { part: EventPart::ToolCall(ToolCallPart::Start { id, name }), .. - } = &event + } + | Event::ToolCallPending { id, name } = &event { // The tool-call boundary is owned here: only the // turn loop holds the per-tool config and @@ -592,6 +622,11 @@ pub(super) async fn run_turn_loop( }); } + if let Event::ToolCallPendingEnd { id } = &event { + tool_renderer.complete(id); + tool_coordinator.discard_pending_tool(id); + } + let is_finished = matches!(event, Event::Finished(_)); // A refusal revokes assistant content the flush below @@ -743,6 +778,13 @@ pub(super) async fn run_turn_loop( } } + drop(streams); + if matches!(execution, ToolExecution::Agent { .. }) + && turn_coordinator.current_phase() == TurnPhase::Executing + { + continuation = Some(raw_stream); + } + // Deregister the streaming interrupt handler; from here the // router treats Ctrl-C as unhandled again. drop(interrupt_guard); @@ -858,21 +900,22 @@ pub(super) async fn run_turn_loop( tool_coordinator.reset_for_execution(); + let mut interrupt_ui = InterruptUi { + turn_coordinator: &mut turn_coordinator, + printer: &printer, + backend: prompt_backend.as_ref(), + editor: build_editor_backend(&cfg.editor, &printer), + edit_mode: reply_edit_mode(cfg.editor.inline.edit_mode), + }; let execution_result = tool_coordinator .execute_with_prompting( approved, Arc::clone(&prompter), signals, - &mut turn_coordinator, &mut turn_state, - &printer, - prompt_backend.as_ref(), - build_editor_backend(&cfg.editor, &printer), - reply_edit_mode(cfg.editor.inline.edit_mode), + &mut interrupt_ui, Arc::clone(&inquiry_backend), &conv, - mcp_client, - root, &mut tool_renderer, interactive, ) @@ -891,7 +934,8 @@ pub(super) async fn run_turn_loop( &mut tool_coordinator, &mut turn_coordinator, &mut conv, - )?; + ) + .await?; return Err(cmd::Error::interrupted().into()); } @@ -908,7 +952,8 @@ pub(super) async fn run_turn_loop( &mut tool_coordinator, &mut turn_coordinator, &mut conv, - )?; + ) + .await?; break; } @@ -925,7 +970,9 @@ pub(super) async fn run_turn_loop( &mut tool_coordinator, &mut turn_coordinator, &mut conv, - )? { + ) + .await? + { tool_choice = ToolChoice::Auto; } } @@ -1152,7 +1199,7 @@ async fn build_inquiry_overrides( /// /// Returns `true` if a follow-up LLM cycle is needed (i.e. tool responses were /// added and the coordinator wants to continue). -fn commit_tool_responses( +async fn commit_tool_responses( result: ExecutionResult, pre_resolved: Vec<(usize, ToolCallResponse)>, tool: &mut ToolCoordinator, @@ -1163,16 +1210,37 @@ fn commit_tool_responses( // permission phase into the corresponding ToolCallRequest events. flush_rendered_arguments(tool, conv); - // Both `result.responses` and `pre_resolved` are already keyed by the + // Both `result.reviews` and `pre_resolved` are already keyed by the // plan index assigned in `build_execution_plan`. Sorting by that // index restores stream order for the persisted responses. - let mut indexed: Vec<(usize, ToolCallResponse)> = result.responses; - indexed.extend(pre_resolved); - indexed.sort_by_key(|(idx, _)| *idx); - let responses: Vec<_> = indexed.into_iter().map(|(_, r)| r).collect(); + // + // A pre-resolved tool never reached an executor, so nothing offered it a + // result to edit. + let mut indexed: Vec<(usize, Review)> = result.reviews; + indexed.extend( + pre_resolved + .into_iter() + .map(|(index, response)| (index, Review::unchanged(response))), + ); + indexed.sort_by_key(|(index, _)| *index); + let reviews: Vec<_> = indexed.into_iter().map(|(_, review)| review).collect(); + let responses = reviews + .iter() + .map(|review| review.response.clone()) + .collect(); let action = conv.update_events(|stream| turn.handle_tool_responses(stream, responses)); conv.flush()?; + // Only now does each call's MCP response reach its caller: the service + // holds every result until the conversation has it on disk. + // + // The conversation is already written at this point, so a failure here is + // the Host and the execution service disagreeing about a call that, from + // the user's side, succeeded. Ending the turn over it would discard work + // that is on disk and about to be answered. + if let Err(error) = tool.acknowledge_reviews(reviews).await { + warn!(%error, "Could not acknowledge a recorded tool response."); + } Ok(matches!(action, Action::SendFollowUp)) } @@ -1218,3 +1286,7 @@ fn map_llm_error(error: jp_llm::Error, models: Vec) -> Error { #[cfg(test)] #[path = "turn_loop_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "agent_turn_tests.rs"] +mod agent_turn_tests; diff --git a/crates/jp_cli/src/cmd/query/turn_loop_tests.rs b/crates/jp_cli/src/cmd/query/turn_loop_tests.rs index fb943061c..850cd6d20 100644 --- a/crates/jp_cli/src/cmd/query/turn_loop_tests.rs +++ b/crates/jp_cli/src/cmd/query/turn_loop_tests.rs @@ -16,13 +16,14 @@ use futures::{StreamExt as _, stream}; use indexmap::IndexMap; use inquire::InquireError; use jp_config::{ - AppConfig, PartialAppConfig, + AppConfig, Config as _, PartialAppConfig, assistant::{ PartialAssistantConfig, request::{CachePolicy, MaxResponseBytes, PartialRequestConfig}, }, conversation::tool::{ - CommandConfigOrString, QuestionConfig, QuestionTarget, RunMode, ToolConfig, ToolSource, + CommandConfigOrString, PartialToolConfig, QuestionConfig, QuestionTarget, RunMode, + ToolConfig, ToolSource, style::{ DisplayStyleConfig, ErrorStyleConfig, InlineResults, LinkStyle, ParametersStyle, TruncateLines, @@ -50,18 +51,14 @@ use jp_llm::{ model::ModelDetails, provider::mock::MockProvider, query::ChatQuery, - tool::{ - InvocationContext, - builtin::BuiltinExecutors, - executor::{ - Executor, ExecutorResult, ExecutorSource, MockExecutor, PermissionInfo, - TestExecutorSource, - }, - }, +}; +use jp_mcp::{ + Client, + server::builtin::{BuiltinExecutors, BuiltinTool}, }; use jp_printer::{OutputFormat, Printer, TerminalCapability}; use jp_storage::backend::FsStorageBackend; -use jp_tool::Question; +use jp_tool::{InvocationContext, Outcome, Question, ToolDocs}; use jp_workspace::Workspace; use serde_json::{Map, Value, json}; use tokio::{sync::Notify, time::timeout}; @@ -69,20 +66,23 @@ use tokio_util::sync::CancellationToken; use super::*; use crate::{ + access::approvals::ApprovalStore, cmd::query::{ stream::retry::MAX_CONSECUTIVE_REBUILDS, - tool::{ToolCoordinator, executor::TerminalExecutorSource}, + tool::{ + ToolCoordinator, + executor::{ + Executor, ExecutorResult, ExecutorSource, PermissionInfo, + mock::{MockExecutor, TestExecutorSource}, + }, + mcp_executor::TerminalExecutorSource, + }, }, signals::testing::{detached_router, test_router}, }; fn empty_executor_source() -> Box { - Box::new(TerminalExecutorSource::new( - BuiltinExecutors::new(), - &[], - std::sync::Arc::new(crate::access::approvals::ApprovalStore::default()), - InvocationContext::default(), - )) + Box::new(TestExecutorSource::new()) } /// A mock provider that returns different responses on each call. @@ -356,7 +356,6 @@ async fn test_interrupt_stop_during_streaming_persists_content() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let (router, signals) = test_router(); let router = Arc::new(router); @@ -382,8 +381,8 @@ async fn test_interrupt_stop_during_streaming_persists_content() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], // attachments &lock, @@ -393,7 +392,6 @@ async fn test_interrupt_stop_during_streaming_persists_content() { Arc::new(backend), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -457,7 +455,6 @@ async fn a_completed_block_is_persisted_before_the_turn_ends() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let (router, signals) = test_router(); let router = Arc::new(router); @@ -482,8 +479,8 @@ async fn a_completed_block_is_persisted_before_the_turn_ends() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // is_tty &[], // attachments &lock, @@ -493,7 +490,6 @@ async fn a_completed_block_is_persisted_before_the_turn_ends() { Arc::new(backend), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), ChatRequest::from("What is 2+2?"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -557,7 +553,6 @@ async fn a_refusal_takes_back_content_it_had_persisted() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let (router, _signals) = test_router(); let router = Arc::new(router); @@ -566,8 +561,8 @@ async fn a_refusal_takes_back_content_it_had_persisted() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, &[], &lock, @@ -577,7 +572,6 @@ async fn a_refusal_takes_back_content_it_had_persisted() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), ChatRequest::from("something declined"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -626,7 +620,6 @@ async fn test_streaming_interrupt_menu_cancel_escalates() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let (router, signals) = test_router(); let router = Arc::new(router); @@ -653,8 +646,8 @@ async fn test_streaming_interrupt_menu_cancel_escalates() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], // attachments &lock, @@ -664,7 +657,6 @@ async fn test_streaming_interrupt_menu_cancel_escalates() { Arc::new(backend), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -729,7 +721,6 @@ async fn test_normal_completion_persists_content() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); run_turn_loop( @@ -737,8 +728,8 @@ async fn test_normal_completion_persists_content() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -748,7 +739,6 @@ async fn test_normal_completion_persists_content() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -812,7 +802,6 @@ async fn premature_stream_end_without_finished_returns_error() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); // Without the backstop the loop pends forever, so cap the whole run. @@ -823,8 +812,8 @@ async fn premature_stream_end_without_finished_returns_error() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -834,7 +823,6 @@ async fn premature_stream_end_without_finished_returns_error() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), ChatRequest::from("hi"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ), @@ -877,7 +865,6 @@ async fn premature_stream_end_exhausts_retry_budget() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let result = timeout( @@ -887,8 +874,8 @@ async fn premature_stream_end_exhausts_retry_budget() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -898,7 +885,6 @@ async fn premature_stream_end_exhausts_retry_budget() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), ChatRequest::from("hi"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ), @@ -957,7 +943,6 @@ async fn output_ceiling_ends_turn_without_re_requesting() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let result = timeout( @@ -967,8 +952,8 @@ async fn output_ceiling_ends_turn_without_re_requesting() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -978,7 +963,6 @@ async fn output_ceiling_ends_turn_without_re_requesting() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), ChatRequest::from("hi"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ), @@ -1064,7 +1048,6 @@ async fn orphan_tool_call_is_sanitized_before_provider_request() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); run_turn_loop( @@ -1072,8 +1055,8 @@ async fn orphan_tool_call_is_sanitized_before_provider_request() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -1083,7 +1066,6 @@ async fn orphan_tool_call_is_sanitized_before_provider_request() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), ChatRequest::from("new query"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -1149,7 +1131,6 @@ async fn test_tool_call_cycle_completes_with_followup() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let result = run_turn_loop( @@ -1157,8 +1138,8 @@ async fn test_tool_call_cycle_completes_with_followup() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -1168,7 +1149,6 @@ async fn test_tool_call_cycle_completes_with_followup() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -1261,10 +1241,8 @@ impl Executor for SleepingExecutor { async fn execute( &self, _answers: &IndexMap, - _mcp_client: &jp_mcp::Client, - _root: &Utf8Path, cancellation_token: CancellationToken, - _stderr: Option, + _stderr: Option, ) -> ExecutorResult { if let Some(started) = &self.started { started.notify_one(); @@ -1421,7 +1399,6 @@ async fn test_tool_interrupt_menu_cancel_escalates() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let (router, signals) = test_router(); let router = Arc::new(router); @@ -1455,8 +1432,8 @@ async fn test_tool_interrupt_menu_cancel_escalates() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -1466,7 +1443,6 @@ async fn test_tool_interrupt_menu_cancel_escalates() { Arc::new(backend), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -1570,7 +1546,6 @@ async fn test_tool_stop_on_interrupt_commits_responses_without_follow_up() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let (router, signals) = test_router(); let router = Arc::new(router); @@ -1604,8 +1579,8 @@ async fn test_tool_stop_on_interrupt_commits_responses_without_follow_up() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -1616,7 +1591,6 @@ async fn test_tool_stop_on_interrupt_commits_responses_without_follow_up() { ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)) .with_interrupt(config.interrupt.tool_call.clone()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -1714,7 +1688,6 @@ async fn test_interrupt_during_tool_prompt_completes_turn_early() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let (router, signals) = test_router(); let router = Arc::new(router); @@ -1749,8 +1722,8 @@ async fn test_interrupt_during_tool_prompt_completes_turn_early() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), true, // interactive: user-targeted question prompts need a user &[], &lock, @@ -1760,7 +1733,6 @@ async fn test_interrupt_during_tool_prompt_completes_turn_early() { Arc::new(backend), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -1853,7 +1825,6 @@ async fn test_multiple_tool_calls_in_sequence() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let result = run_turn_loop( @@ -1861,8 +1832,8 @@ async fn test_multiple_tool_calls_in_sequence() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -1872,7 +1843,6 @@ async fn test_multiple_tool_calls_in_sequence() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -1944,7 +1914,6 @@ async fn test_empty_tool_response_continues_cycle() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let result = run_turn_loop( @@ -1952,8 +1921,8 @@ async fn test_empty_tool_response_continues_cycle() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -1963,7 +1932,6 @@ async fn test_empty_tool_response_continues_cycle() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -2054,7 +2022,6 @@ async fn test_tool_restart_on_interrupt() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let (router, signals) = test_router(); let router = Arc::new(router); @@ -2098,8 +2065,8 @@ async fn test_tool_restart_on_interrupt() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -2109,7 +2076,6 @@ async fn test_tool_restart_on_interrupt() { Arc::new(backend), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -2210,7 +2176,6 @@ async fn test_merged_stream_exits_after_tool_response() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); // No signals sent - the turn loop should complete naturally after @@ -2220,8 +2185,8 @@ async fn test_merged_stream_exits_after_tool_response() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -2231,7 +2196,6 @@ async fn test_merged_stream_exits_after_tool_response() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -2321,7 +2285,6 @@ async fn test_tool_call_with_run_mode_ask_approves() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); // Mock: user presses 'y' to approve @@ -2348,8 +2311,8 @@ async fn test_tool_call_with_run_mode_ask_approves() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), true, // interactive = true to enable prompts &[], &lock, @@ -2359,7 +2322,6 @@ async fn test_tool_call_with_run_mode_ask_approves() { Arc::new(backend), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -2465,7 +2427,6 @@ async fn test_tool_call_with_run_mode_ask_skips() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); // Mock: user presses 'n' to skip @@ -2491,8 +2452,8 @@ async fn test_tool_call_with_run_mode_ask_skips() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), true, // interactive &[], &lock, @@ -2502,7 +2463,6 @@ async fn test_tool_call_with_run_mode_ask_skips() { Arc::new(backend), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -2620,7 +2580,6 @@ async fn test_permission_prompt_follows_interactive_not_is_tty() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let backend = MockPromptBackend::new().with_inline_responses(['n']); @@ -2645,8 +2604,8 @@ async fn test_permission_prompt_follows_interactive_not_is_tty() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), true, // interactive: the user is still at the terminal &[], &lock, @@ -2656,7 +2615,6 @@ async fn test_permission_prompt_follows_interactive_not_is_tty() { Arc::new(backend), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -2747,7 +2705,6 @@ async fn test_tool_call_with_run_mode_unattended() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); // No prompt responses needed - tool runs without asking @@ -2769,8 +2726,8 @@ async fn test_tool_call_with_run_mode_unattended() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), true, // interactive doesn't matter for Unattended &[], &lock, @@ -2780,7 +2737,6 @@ async fn test_tool_call_with_run_mode_unattended() { Arc::new(backend), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -2886,7 +2842,6 @@ async fn test_tool_call_with_run_mode_skip() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); // No prompt responses needed - tool is skipped automatically @@ -2917,8 +2872,8 @@ async fn test_tool_call_with_run_mode_skip() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), true, // interactive &[], &lock, @@ -2928,7 +2883,6 @@ async fn test_tool_call_with_run_mode_skip() { Arc::new(backend), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -3087,7 +3041,6 @@ async fn test_multiple_tools_with_different_run_modes() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); // User presses 'y' to approve the Ask tool @@ -3121,8 +3074,8 @@ async fn test_multiple_tools_with_different_run_modes() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), true, // interactive &[], &lock, @@ -3132,7 +3085,6 @@ async fn test_multiple_tools_with_different_run_modes() { Arc::new(backend), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -3250,7 +3202,6 @@ async fn test_tool_call_returns_error() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let backend = MockPromptBackend::new(); @@ -3270,8 +3221,8 @@ async fn test_tool_call_returns_error() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), true, // interactive &[], &lock, @@ -3281,7 +3232,6 @@ async fn test_tool_call_returns_error() { Arc::new(backend), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -3455,6 +3405,15 @@ impl Provider for PacedMockProvider { } } +#[test] +fn pending_tool_progress_releases_the_generic_waiting_indicator() { + let event = StreamingLoopEvent::Llm(Box::new(Ok(Event::ToolCallPending { + id: "pending".into(), + name: "fs_create_file".into(), + }))); + assert!(!event_keeps_waiting_indicator(&event)); +} + #[tokio::test(flavor = "multi_thread")] async fn test_waiting_indicator_shows_during_delay() { // Tests that the waiting indicator appears when the LLM takes longer @@ -3498,7 +3457,6 @@ async fn test_waiting_indicator_shows_during_delay() { let (printer, out, err) = Printer::memory(OutputFormat::TextPretty); // The status region only renders against a terminal it has to itself. let printer = Arc::new(printer.with_terminal(TerminalCapability::interactive(Some(80)))); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); run_turn_loop( @@ -3506,8 +3464,8 @@ async fn test_waiting_indicator_shows_during_delay() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -3517,7 +3475,6 @@ async fn test_waiting_indicator_shows_during_delay() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -3599,7 +3556,6 @@ async fn test_waiting_indicator_survives_keep_alive_and_shows_status() { let (printer, out, err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer.with_terminal(TerminalCapability::interactive(Some(80)))); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); run_turn_loop( @@ -3607,8 +3563,8 @@ async fn test_waiting_indicator_survives_keep_alive_and_shows_status() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), true, // interactive &[], &lock, @@ -3618,7 +3574,6 @@ async fn test_waiting_indicator_survives_keep_alive_and_shows_status() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -3714,7 +3669,6 @@ async fn test_waiting_indicator_cleared_before_retry_notice() { let (printer, out, err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer.with_terminal(TerminalCapability::interactive(Some(80)))); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); run_turn_loop( @@ -3722,8 +3676,8 @@ async fn test_waiting_indicator_cleared_before_retry_notice() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), true, // interactive &[], &lock, @@ -3733,7 +3687,6 @@ async fn test_waiting_indicator_cleared_before_retry_notice() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -3802,7 +3755,6 @@ async fn test_waiting_indicator_not_shown_when_disabled() { // A terminal is available; `show = false` is what turns the indicator // off, so the region must stay inert on its own. let printer = Arc::new(printer.with_terminal(TerminalCapability::interactive(Some(80)))); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); run_turn_loop( @@ -3810,8 +3762,8 @@ async fn test_waiting_indicator_not_shown_when_disabled() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), true, // interactive &[], &lock, @@ -3821,7 +3773,6 @@ async fn test_waiting_indicator_not_shown_when_disabled() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -3882,7 +3833,6 @@ async fn test_waiting_indicator_not_shown_for_non_tty() { // The default capability models a piped stderr. let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); run_turn_loop( @@ -3890,8 +3840,8 @@ async fn test_waiting_indicator_not_shown_for_non_tty() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), true, // interactive &[], &lock, @@ -3901,7 +3851,6 @@ async fn test_waiting_indicator_not_shown_for_non_tty() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -3963,7 +3912,6 @@ async fn test_waiting_indicator_follows_stderr_not_stdout() { // though stdout is a terminal. let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); run_turn_loop( @@ -3971,8 +3919,8 @@ async fn test_waiting_indicator_follows_stderr_not_stdout() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), true, // interactive &[], &lock, @@ -3982,7 +3930,6 @@ async fn test_waiting_indicator_follows_stderr_not_stdout() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -4149,7 +4096,6 @@ async fn test_multi_part_tool_call_shows_preparing_spinner() { let (printer, out, err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer.with_terminal(terminal)); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let result = run_turn_loop( @@ -4157,8 +4103,8 @@ async fn test_multi_part_tool_call_shows_preparing_spinner() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), true, // interactive &[], &lock, @@ -4168,7 +4114,6 @@ async fn test_multi_part_tool_call_shows_preparing_spinner() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -4237,7 +4182,6 @@ async fn test_turn_start_event_is_emitted() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); run_turn_loop( @@ -4245,8 +4189,8 @@ async fn test_turn_start_event_is_emitted() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -4256,7 +4200,6 @@ async fn test_turn_start_event_is_emitted() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -4289,8 +4232,6 @@ async fn test_turn_start_index_increments_across_turns() { .create_and_lock_conversation(Conversation::default(), config.clone().into(), None) .unwrap(); - let mcp_client = jp_mcp::Client::default(); - // First turn. let chat_request = ChatRequest::from("First question"); @@ -4309,8 +4250,8 @@ async fn test_turn_start_index_increments_across_turns() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -4320,7 +4261,6 @@ async fn test_turn_start_index_increments_across_turns() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -4345,8 +4285,8 @@ async fn test_turn_start_index_increments_across_turns() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -4356,7 +4296,6 @@ async fn test_turn_start_index_increments_across_turns() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -4433,7 +4372,6 @@ async fn test_markdown_flushed_before_tool_header() { let (printer, out, err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer.with_terminal(terminal)); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); run_turn_loop( @@ -4441,8 +4379,8 @@ async fn test_markdown_flushed_before_tool_header() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), true, // interactive &[], &lock, @@ -4452,7 +4390,6 @@ async fn test_markdown_flushed_before_tool_header() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -4605,7 +4542,6 @@ async fn test_parallel_tool_calls_rendered_atomically() { let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new() @@ -4628,8 +4564,8 @@ async fn test_parallel_tool_calls_rendered_atomically() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -4639,7 +4575,6 @@ async fn test_parallel_tool_calls_rendered_atomically() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -4697,8 +4632,8 @@ async fn test_parallel_tool_calls_rendered_atomically() { /// Verifies that a single tool call uses "Calling tool" (singular), and that /// its header+arguments are rendered atomically. -#[tokio::test] #[expect(clippy::too_many_lines)] +#[tokio::test] async fn test_single_tool_call_rendered_with_args() { let test_result = Box::pin(timeout(Duration::from_secs(5), async { let tmp = tempdir().unwrap(); @@ -4772,7 +4707,6 @@ async fn test_single_tool_call_rendered_with_args() { let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new().with_executor("fs_read_file", |req| { @@ -4788,8 +4722,8 @@ async fn test_single_tool_call_rendered_with_args() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -4799,7 +4733,6 @@ async fn test_single_tool_call_rendered_with_args() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -4893,10 +4826,8 @@ impl Executor for TalkingExecutor { async fn execute( &self, _answers: &IndexMap, - _mcp_client: &jp_mcp::Client, - _root: &Utf8Path, _cancellation_token: CancellationToken, - stderr: Option, + stderr: Option, ) -> ExecutorResult { if let Some(sink) = stderr { self.got_sink.store(true, Ordering::Relaxed); @@ -5013,7 +4944,6 @@ async fn a_running_tools_stderr_reaches_the_progress_window() { let printer = Arc::new( printer.with_terminal(TerminalCapability::interactive(Some(80)).with_rows(Some(24))), ); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let got_sink = Arc::new(AtomicBool::new(false)); @@ -5035,8 +4965,8 @@ async fn a_running_tools_stderr_reaches_the_progress_window() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -5046,7 +4976,6 @@ async fn a_running_tools_stderr_reaches_the_progress_window() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), ChatRequest::from("Build it"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -5099,7 +5028,6 @@ async fn parallel_tools_label_their_window_rows() { let printer = Arc::new( printer.with_terminal(TerminalCapability::interactive(Some(80)).with_rows(Some(24))), ); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let seen = Arc::new(AtomicBool::new(false)); @@ -5133,8 +5061,8 @@ async fn parallel_tools_label_their_window_rows() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -5144,7 +5072,6 @@ async fn parallel_tools_label_their_window_rows() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), ChatRequest::from("Run both"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -5185,7 +5112,10 @@ async fn parallel_tools_label_their_window_rows() { /// region frame lands inside the link's line — not about the text being /// present at all. #[tokio::test(flavor = "multi_thread")] -#[expect(clippy::too_many_lines)] +#[expect( + clippy::too_many_lines, + reason = "One linear window-lifecycle scenario" +)] async fn a_tool_result_survives_a_live_window() { let test_result = Box::pin(timeout(Duration::from_secs(5), async { let tmp = tempdir().unwrap(); @@ -5226,7 +5156,6 @@ async fn a_tool_result_survives_a_live_window() { let printer = Arc::new( printer.with_terminal(TerminalCapability::interactive(Some(80)).with_rows(Some(24))), ); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let seen = Arc::new(AtomicBool::new(false)); @@ -5256,8 +5185,8 @@ async fn a_tool_result_survives_a_live_window() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -5267,7 +5196,6 @@ async fn a_tool_result_survives_a_live_window() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), ChatRequest::from("Run both"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -5359,7 +5287,6 @@ async fn a_sink_survives_the_re_spawn_an_answer_triggers() { let printer = Arc::new( printer.with_terminal(TerminalCapability::interactive(Some(80)).with_rows(Some(24))), ); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new().with_executor("asking_tool", |req| { @@ -5372,8 +5299,8 @@ async fn a_sink_survives_the_re_spawn_an_answer_triggers() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -5383,7 +5310,6 @@ async fn a_sink_survives_the_re_spawn_an_answer_triggers() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), ChatRequest::from("Ask then work"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -5412,8 +5338,8 @@ async fn a_sink_survives_the_re_spawn_an_answer_triggers() { /// Rows are screen space, so the window's size is global. /// Membership is not: `conversation.tools..style.print_stderr` keeps one /// noisy tool out without shrinking the window for everything else. -#[tokio::test(flavor = "multi_thread")] #[expect(clippy::too_many_lines)] +#[tokio::test(flavor = "multi_thread")] async fn a_tool_can_opt_out_of_the_progress_window() { let test_result = Box::pin(timeout(Duration::from_secs(5), async { let tmp = tempdir().unwrap(); @@ -5472,7 +5398,6 @@ async fn a_tool_can_opt_out_of_the_progress_window() { let printer = Arc::new( printer.with_terminal(TerminalCapability::interactive(Some(80)).with_rows(Some(24))), ); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let loud_got_sink = Arc::new(AtomicBool::new(false)); @@ -5507,8 +5432,8 @@ async fn a_tool_can_opt_out_of_the_progress_window() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -5518,7 +5443,6 @@ async fn a_tool_can_opt_out_of_the_progress_window() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), ChatRequest::from("Run both"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -5714,7 +5638,6 @@ async fn a_tool_prompt_hides_the_window_and_restores_it() { let printer = Arc::new( printer.with_terminal(TerminalCapability::interactive(Some(80)).with_rows(Some(24))), ); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let prompts = Arc::new(ObservingPromptBackend::new( @@ -5732,8 +5655,8 @@ async fn a_tool_prompt_hides_the_window_and_restores_it() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), true, // interactive: a user-targeted question needs a user &[], &lock, @@ -5743,7 +5666,6 @@ async fn a_tool_prompt_hides_the_window_and_restores_it() { Arc::clone(&prompts) as Arc, ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), ChatRequest::from("Ask me"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -5836,10 +5758,8 @@ impl Executor for AskingTalkingExecutor { async fn execute( &self, answers: &IndexMap, - _mcp_client: &jp_mcp::Client, - _root: &Utf8Path, _cancellation_token: CancellationToken, - stderr: Option, + stderr: Option, ) -> ExecutorResult { if answers.contains_key("which") { if let Some(sink) = stderr { @@ -5910,10 +5830,8 @@ impl Executor for InquiryMockExecutor { async fn execute( &self, answers: &IndexMap, - _mcp_client: &jp_mcp::Client, - _root: &camino::Utf8Path, _cancellation_token: tokio_util::sync::CancellationToken, - _stderr: Option, + _stderr: Option, ) -> ExecutorResult { for q in &self.questions { if !answers.contains_key(q.id.as_str()) { @@ -5926,7 +5844,7 @@ impl Executor for InquiryMockExecutor { }; } } - ExecutorResult::Completed(jp_conversation::event::ToolCallResponse { + ExecutorResult::Completed(ToolCallResponse { id: self.tool_id.clone(), result: Ok(self.output.clone()), }) @@ -6195,8 +6113,8 @@ async fn inquiry_ceiling_honors_the_per_question_override() { /// Tool has one boolean question with `QuestionTarget::Assistant`. /// Flow: LLM tool call → `NeedsInput` → inquiry → answer → tool completes. -#[tokio::test] #[expect(clippy::too_many_lines)] +#[tokio::test] async fn test_tool_with_single_inquiry() { let test_result = Box::pin(timeout(Duration::from_secs(5), async { let tmp = tempdir().unwrap(); @@ -6239,7 +6157,6 @@ async fn test_tool_with_single_inquiry() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new().with_executor("inquiry_tool", |req| { @@ -6257,8 +6174,8 @@ async fn test_tool_with_single_inquiry() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -6268,7 +6185,6 @@ async fn test_tool_with_single_inquiry() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -6368,7 +6284,6 @@ async fn test_secret_question_without_tty_fails_tool() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new().with_executor("secret_tool", |req| { @@ -6386,8 +6301,8 @@ async fn test_secret_question_without_tty_fails_tool() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -6397,7 +6312,6 @@ async fn test_secret_question_without_tty_fails_tool() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -6478,7 +6392,6 @@ async fn test_secret_question_with_assistant_target_fails_tool() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new().with_executor("secret_tool", |req| { @@ -6496,8 +6409,8 @@ async fn test_secret_question_with_assistant_target_fails_tool() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -6507,7 +6420,6 @@ async fn test_secret_question_with_assistant_target_fails_tool() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -6581,7 +6493,6 @@ async fn test_secret_prompter_answer_is_redacted() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new().with_executor("secret_tool", |req| { @@ -6599,8 +6510,8 @@ async fn test_secret_prompter_answer_is_redacted() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), true, // interactive &[], &lock, @@ -6610,7 +6521,6 @@ async fn test_secret_prompter_answer_is_redacted() { Arc::new(MockPromptBackend::new().with_password_responses(["s3cret"])), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -6688,7 +6598,6 @@ async fn test_secret_static_answer_is_redacted() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new().with_executor("secret_tool", |req| { @@ -6706,8 +6615,8 @@ async fn test_secret_static_answer_is_redacted() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -6717,7 +6626,6 @@ async fn test_secret_static_answer_is_redacted() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -6796,7 +6704,6 @@ async fn test_static_answer_records_answered_inquiry() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new().with_executor("static_tool", |req| { @@ -6814,8 +6721,8 @@ async fn test_static_answer_records_answered_inquiry() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -6825,7 +6732,6 @@ async fn test_static_answer_records_answered_inquiry() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -6907,7 +6813,6 @@ async fn test_remembered_answer_cache_hit_records_new_inquiry_pair() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new().with_executor("cached_tool", |req| { @@ -6930,8 +6835,8 @@ async fn test_remembered_answer_cache_hit_records_new_inquiry_pair() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), true, // interactive &[], &lock, @@ -6941,7 +6846,6 @@ async fn test_remembered_answer_cache_hit_records_new_inquiry_pair() { prompt_backend, ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -7032,7 +6936,6 @@ async fn test_tool_with_multiple_inquiries() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new().with_executor("multi_q_tool", |req| { @@ -7053,8 +6956,8 @@ async fn test_tool_with_multiple_inquiries() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -7064,7 +6967,6 @@ async fn test_tool_with_multiple_inquiries() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -7112,8 +7014,8 @@ async fn test_tool_with_multiple_inquiries() { /// Two parallel tools: one requires an inquiry, the other completes normally. /// The inquiry should not block the normal tool from completing. -#[tokio::test] #[expect(clippy::too_many_lines)] +#[tokio::test] async fn test_parallel_tools_one_with_inquiry() { let test_result = Box::pin(timeout(Duration::from_secs(5), async { let tmp = tempdir().unwrap(); @@ -7184,7 +7086,6 @@ async fn test_parallel_tools_one_with_inquiry() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new() @@ -7206,8 +7107,8 @@ async fn test_parallel_tools_one_with_inquiry() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -7217,7 +7118,6 @@ async fn test_parallel_tools_one_with_inquiry() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -7260,8 +7160,8 @@ async fn test_parallel_tools_one_with_inquiry() { /// Two parallel tools both requiring inquiries. /// Uses responses without `inquiry_id` since the concurrent inquiry call order /// is non-deterministic. -#[tokio::test] #[expect(clippy::too_many_lines)] +#[tokio::test] async fn test_parallel_tools_both_with_inquiries() { let test_result = Box::pin(timeout(Duration::from_secs(5), async { let tmp = tempdir().unwrap(); @@ -7318,7 +7218,6 @@ async fn test_parallel_tools_both_with_inquiries() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new() @@ -7345,8 +7244,8 @@ async fn test_parallel_tools_both_with_inquiries() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -7356,7 +7255,6 @@ async fn test_parallel_tools_both_with_inquiries() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -7488,7 +7386,6 @@ async fn test_retry_counter_resets_on_successful_event() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let result = run_turn_loop( @@ -7496,8 +7393,8 @@ async fn test_retry_counter_resets_on_successful_event() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -7507,7 +7404,6 @@ async fn test_retry_counter_resets_on_successful_event() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -7620,7 +7516,6 @@ async fn test_unavailable_tool_before_approved_does_not_panic() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); // Only `ok_tool` is registered with the executor source; the @@ -7636,8 +7531,8 @@ async fn test_unavailable_tool_before_approved_does_not_panic() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -7647,7 +7542,6 @@ async fn test_unavailable_tool_before_approved_does_not_panic() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -7729,7 +7623,6 @@ async fn test_inquiry_failure_marks_tool_as_error() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new().with_executor("inquiry_tool", |req| { @@ -7747,8 +7640,8 @@ async fn test_inquiry_failure_marks_tool_as_error() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -7758,7 +7651,6 @@ async fn test_inquiry_failure_marks_tool_as_error() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -7934,7 +7826,6 @@ async fn test_live_header_uses_configured_model_id_not_provider_returned() { // The live role header is chrome, so it lands on the error stream. let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); run_turn_loop( @@ -7942,8 +7833,8 @@ async fn test_live_header_uses_configured_model_id_not_provider_returned() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -7953,7 +7844,6 @@ async fn test_live_header_uses_configured_model_id_not_provider_returned() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -8045,7 +7935,6 @@ async fn reasoning_before_a_tool_call_shades_the_tool_chrome() { let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new().with_executor("mock_tool", |req| { @@ -8058,8 +7947,8 @@ async fn reasoning_before_a_tool_call_shades_the_tool_chrome() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -8069,7 +7958,6 @@ async fn reasoning_before_a_tool_call_shades_the_tool_chrome() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), ChatRequest::from("use the tool"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -8170,7 +8058,6 @@ async fn a_tool_that_does_not_join_reasoning_renders_unshaded_live() { let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new().with_executor("mock_tool", |req| { @@ -8183,8 +8070,8 @@ async fn a_tool_that_does_not_join_reasoning_renders_unshaded_live() { &model, &config, &router, - &mcp_client, root, + InvocationContext::default(), false, // interactive &[], &lock, @@ -8194,7 +8081,6 @@ async fn a_tool_that_does_not_join_reasoning_renders_unshaded_live() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), ChatRequest::from("use the tool"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -8306,7 +8192,6 @@ async fn test_rebuild_cap_stops_a_provider_that_keeps_requesting_rebuilds() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let (router, _signals) = test_router(); let router = Arc::new(router); @@ -8315,8 +8200,8 @@ async fn test_rebuild_cap_stops_a_provider_that_keeps_requesting_rebuilds() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -8326,7 +8211,6 @@ async fn test_rebuild_cap_stops_a_provider_that_keeps_requesting_rebuilds() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), ChatRequest::from("repair this"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -8399,7 +8283,6 @@ async fn test_refused_rebuild_clears_the_retry_line() { // The notice only takes a status region on a terminal; elsewhere it is // a persistent line with nothing to retire. let printer = Arc::new(printer.with_terminal(TerminalCapability::interactive(Some(80)))); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let result = run_turn_loop( @@ -8407,8 +8290,8 @@ async fn test_refused_rebuild_clears_the_retry_line() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), true, // interactive &[], &lock, @@ -8418,7 +8301,6 @@ async fn test_refused_rebuild_clears_the_retry_line() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), ChatRequest::from("answer this"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -8483,7 +8365,6 @@ async fn test_refused_rebuild_persists_streamed_content() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let (router, _signals) = test_router(); let router = Arc::new(router); @@ -8492,8 +8373,8 @@ async fn test_refused_rebuild_persists_streamed_content() { &model, &config, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], &lock, @@ -8503,7 +8384,6 @@ async fn test_refused_rebuild_persists_streamed_content() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), ChatRequest::from("answer this"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -8523,3 +8403,130 @@ async fn test_refused_rebuild_persists_streamed_content() { "streamed content must survive the abort.\nFile contents:\n{content}" ); } + +struct HttpInquiryTool(Arc); + +#[async_trait] +impl BuiltinTool for HttpInquiryTool { + async fn execute(&self, _: &Value, answers: &IndexMap) -> Outcome { + self.0.fetch_add(1, Ordering::SeqCst); + if answers.get("confirm") == Some(&json!(true)) { + return "confirmed".into(); + } + Question::boolean("confirm", "Continue?").unwrap().into() + } +} + +#[tokio::test] +#[expect( + clippy::too_many_lines, + reason = "Keep the end-to-end setup and persisted assertions in one scenario" +)] +async fn http_tool_cycle_persists_inquiry_and_response_before_followup() { + timeout(Duration::from_secs(10), async { + let tmp = tempdir().unwrap(); + let root = tmp.path(); + let mut config = AppConfig::new_test(); + let partial: PartialToolConfig = serde_json::from_value(json!({ + "source":"builtin", "run":"unattended", "style":{"hidden":true}, + "questions":{"confirm":{"answer":true}} + })) + .unwrap(); + config.conversation.tools.insert( + "http_tool".into(), + ToolConfig::from_partial(partial, vec![]).unwrap(), + ); + let storage = Arc::new(FsStorageBackend::new(&root.join(".jp")).unwrap()); + let mut workspace = Workspace::in_memory(root).with_backend(storage.clone()); + let lock = workspace + .create_and_lock_conversation(Conversation::default(), config.clone().into(), None) + .unwrap(); + let definitions = vec![ToolDefinition { + name: "http_tool".into(), + docs: ToolDocs::default(), + parameters: json!({"type":"object","properties":{}}), + }]; + let count = Arc::new(AtomicUsize::new(0)); + let client = Client::default(); + let (source, owner) = TerminalExecutorSource::start( + BuiltinExecutors::new().register("http_tool", HttpInquiryTool(count.clone())), + &definitions, + &config.conversation.tools, + Arc::new(ApprovalStore::default()), + InvocationContext::default(), + &client, + root.to_owned(), + ) + .await + .unwrap(); + let provider = Arc::new(SequentialMockProvider::with_tool_then_message( + "http-call", + "http_tool", + "Finished.", + )); + let model = provider + .model_details(&"test-model".parse().unwrap()) + .await + .unwrap(); + let router = detached_router(); + let (printer, output, chrome) = Printer::memory(OutputFormat::TextPretty); + let printer = Arc::new(printer); + run_turn_loop( + provider.clone(), + &model, + &config, + &router, + Utf8Path::new("/tmp"), + InvocationContext::default(), + false, + &[], + &lock, + ToolChoice::Auto, + &definitions, + printer.clone(), + Arc::new(MockPromptBackend::new()), + ToolCoordinator::new(config.conversation.tools.clone(), Box::new(source)), + ChatRequest::from("Run the tool."), + PendingStreamTrim::default(), + router.turn_interrupt(lock.id()), + ) + .await + .unwrap(); + // Storage encodes tool content; use the production decoder before + // comparing domain events rather than deserializing individual records. + let stored = + serde_json::from_str(&storage.read_test_events_raw(&lock.id()).unwrap()).unwrap(); + let events = + ConversationStream::from_parts(json!({}), stored, &config.clone().into()).unwrap(); + let responses = events + .iter() + .filter_map(|event| event.event.as_tool_call_response()) + .cloned() + .collect::>(); + assert_eq!(responses, vec![ToolCallResponse { + id: "http-call".into(), + result: Ok("confirmed".into()) + }]); + let answers = events + .iter() + .filter_map(|event| event.event.as_inquiry_response()) + .filter_map(|answer| match answer { + InquiryResponse::Answered { answer, .. } => Some(answer.clone()), + _ => None, + }) + .collect::>(); + assert_eq!(answers, vec![json!(true)]); + assert_eq!(count.load(Ordering::SeqCst), 2); + assert_eq!(provider.call_index.load(Ordering::SeqCst), 2); + printer.flush(); + assert_eq!(output.lock().as_str(), "Finished.\n\n"); + assert_eq!( + chrome.lock().as_str(), + "\n── \x1b[1mjp\x1b[0m \x1b[2m(anthropic/test)\x1b[0m \ + ─────────────────────────────────────────────────────────\n\n" + ); + owner.shutdown().await.unwrap(); + }) + .await + .unwrap(); +} diff --git a/crates/jp_cli/src/cmd/query_tests.rs b/crates/jp_cli/src/cmd/query_tests.rs index e856400d2..023d1b401 100644 --- a/crates/jp_cli/src/cmd/query_tests.rs +++ b/crates/jp_cli/src/cmd/query_tests.rs @@ -12,7 +12,6 @@ use jp_config::{ ResultMode, RunMode, access::PartialEnvRuleConfig, }, model::id::{ModelIdConfig, PartialModelIdConfig, ProviderId}, - style::stderr_rows::{RowCount, StderrRows}, types::command::CommandConfigOrString, util::build, }; @@ -21,18 +20,13 @@ use jp_conversation::{ event::{ChatRequest, ChatResponse}, }; use jp_inquire::prompt::MockPromptBackend; -use jp_llm::{ - Provider, - provider::mock::MockProvider, - tool::{InvocationContext, builtin::BuiltinExecutors, executor::ExecutorSource}, -}; -use jp_mcp::{Startup, StderrLine}; -use jp_printer::{OutputFormat, Printer, SharedBuffer, TerminalCapability}; +use jp_llm::{Provider, provider::mock::MockProvider}; +use jp_mcp::{Startup, StartupSet, id::McpServerId, server::tool_definitions}; +use jp_printer::{OutputFormat, Printer, SharedBuffer}; use jp_storage::{ backend::{ConversationFilter, FsStorageBackend, LoadBackend}, load::projected_conversation_ids, }; -use jp_term::width::display_width; use jp_workspace::{ ConversationHandle, Workspace, session::{Session, SessionId, SessionSource}, @@ -41,7 +35,10 @@ use relative_path::RelativePathBuf; use serde_json::Value; use tokio::{runtime::Runtime, sync::broadcast}; -use super::*; +use super::{ + tool::executor::{ExecutorSource, mock::TestExecutorSource}, + *, +}; use crate::{ Cli, Globals, KeyValueOrPath, cmd::target::{ConversationTarget, PickerFilter}, @@ -258,12 +255,7 @@ fn config_with_model(provider: ProviderId, name: &str) -> AppConfig { } fn empty_executor_source() -> Box { - Box::new(tool::executor::TerminalExecutorSource::new( - BuiltinExecutors::new(), - &[], - std::sync::Arc::new(crate::access::approvals::ApprovalStore::default()), - InvocationContext::default(), - )) + Box::new(TestExecutorSource::new()) } fn build_query_config( @@ -326,14 +318,19 @@ async fn an_interrupt_during_mcp_startup_stops_the_turn_before_it_runs() { let lock = workspace.test_lock(handle); // Held open for the life of the test: the server never finishes starting, - // so `await_mcp_servers` never returns on its own. + // so the startup wait never returns on its own. let (_release, release_rx) = tokio::sync::oneshot::channel::<()>(); let mut joins = tokio::task::JoinSet::new(); joins.spawn(async move { release_rx.await.ok(); Ok(Startup::Ready(McpServerId::new("bookworm"))) }); - let (mcp_servers, _lines) = startup_set(joins, vec![McpServerId::new("bookworm")]); + let (_lines, stderr) = broadcast::channel(1); + let mcp_servers = StartupSet { + joins, + pending: vec![McpServerId::new("bookworm")], + stderr, + }; let router = detached_router(); let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); @@ -344,7 +341,7 @@ async fn an_interrupt_during_mcp_startup_stops_the_turn_before_it_runs() { mcp_client: jp_mcp::Client::default(), workspace_root: tmp.path().to_path_buf(), interactive: false, - attachments: PendingAttachments { slots: vec![] }, + attachments: vec![], printer: Arc::new(printer), approvals: Arc::new(crate::access::approvals::ApprovalStore::default()), chat_request: ChatRequest::from("hello"), @@ -378,7 +375,6 @@ async fn an_interrupt_during_mcp_startup_stops_the_turn_before_it_runs() { } async fn run_mock_turn( - root: &camino::Utf8Path, cfg: &AppConfig, lock: &jp_workspace::ConversationLock, prompt: &str, @@ -391,7 +387,6 @@ async fn run_mock_turn( .unwrap(); let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); turn_loop::run_turn_loop( @@ -399,8 +394,8 @@ async fn run_mock_turn( &model, cfg, &router, - &mcp_client, - root, + Utf8Path::new("/tmp"), + InvocationContext::default(), false, // interactive &[], lock, @@ -410,7 +405,6 @@ async fn run_mock_turn( Arc::new(MockPromptBackend::new()), tool::ToolCoordinator::new(cfg.conversation.tools.clone(), empty_executor_source()), ChatRequest::from(prompt), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -1410,7 +1404,7 @@ async fn test_describe_tools_reaches_the_llm_tool_list() { let cfg = build(partial).unwrap(); let client = jp_mcp::Client::new(IndexMap::new()); - let defs = jp_llm::tool::tool_definitions(cfg.conversation.tools.iter(), &client, None) + let defs = tool_definitions(cfg.conversation.tools.iter(), &client, None) .await .unwrap(); @@ -1892,14 +1886,7 @@ async fn query_sequence_new_cfg_profile_then_model_override_persists_for_plain_q .create_and_lock_conversation(Conversation::default(), Arc::new(cfg1.clone()), None) .unwrap(); let conversation_id = lock1.id(); - run_mock_turn( - root, - &cfg1, - &lock1, - "is this thing on?", - "Yes, loud and clear.", - ) - .await; + run_mock_turn(&cfg1, &lock1, "is this thing on?", "Yes, loud and clear.").await; drop(lock1); let handle2 = workspace.acquire_conversation(&conversation_id).unwrap(); @@ -1916,7 +1903,7 @@ async fn query_sequence_new_cfg_profile_then_model_override_persists_for_plain_q lock2 .as_mut() .update_events(|events| events.add_config_delta(delta)); - run_mock_turn(root, &cfg2, &lock2, "are you there?", "Yes.").await; + run_mock_turn(&cfg2, &lock2, "are you there?", "Yes.").await; drop(lock2); let handle3 = workspace.acquire_conversation(&conversation_id).unwrap(); @@ -2020,66 +2007,6 @@ fn cleanup_any_removes_a_replaced_draft() { assert!(!path.exists()); } -fn attachment(source: &str) -> Attachment { - Attachment { - source: source.to_owned(), - description: None, - content: jp_attachment::AttachmentContent::Text(String::new()), - } -} - -/// An MCP attachment resolves later than the rest, but the assistant has to see -/// every attachment in the order the conversation declares them: each one is -/// sent as a document numbered by its position. -#[test] -fn attachments_keep_their_configured_order_across_deferral() { - let mcp = Url::parse("mcp+server+res://one").unwrap(); - - // Declared as `[mcp, file, mcp, file]`, so both MCP slots resolve after the - // two around them and every one of them has to land back in place. - let slots = vec![ - AttachmentSlot::Deferred(mcp.clone()), - AttachmentSlot::Ready(vec![attachment("file://second")]), - AttachmentSlot::Deferred(mcp), - AttachmentSlot::Ready(vec![attachment("file://fourth")]), - ]; - - let resolved = vec![vec![attachment("mcp://first")], vec![attachment( - "mcp://third", - )]]; - - let sources: Vec = splice(slots, resolved) - .into_iter() - .map(|attachment| attachment.source) - .collect(); - - assert_eq!(sources, [ - "mcp://first", - "file://second", - "mcp://third", - "file://fourth" - ]); -} - -/// One URL can yield several attachments, so a slot holds a group rather than a -/// single item and the whole group belongs at the slot's position. -#[test] -fn a_deferred_slot_keeps_its_whole_group_together() { - let slots = vec![ - AttachmentSlot::Deferred(Url::parse("mcp+server+res://dir").unwrap()), - AttachmentSlot::Ready(vec![attachment("file://last")]), - ]; - - let resolved = vec![vec![attachment("mcp://a"), attachment("mcp://b")]]; - - let sources: Vec = splice(slots, resolved) - .into_iter() - .map(|attachment| attachment.source) - .collect(); - - assert_eq!(sources, ["mcp://a", "mcp://b", "file://last"]); -} - #[test] fn resolve_new_title_uses_leading_heading() { assert_eq!( @@ -2823,455 +2750,6 @@ fn pending_trim_default_is_noop() { ); } -#[test] -fn mcp_startup_status_single_server() { - assert_eq!( - mcp_startup_status(&[McpServerId::new("bookworm")]), - "MCP server bookworm" - ); -} - -#[test] -fn mcp_startup_status_multiple_servers() { - assert_eq!( - mcp_startup_status(&[McpServerId::new("bookworm"), McpServerId::new("grizzly")]), - "2 MCP servers (bookworm, grizzly)" - ); -} - -/// Timer settings that render immediately, so tests don't wait out a delay. -fn immediate_mcp_startup_config() -> McpStartupConfig { - McpStartupConfig { - show: true, - delay_secs: 0, - interval_ms: 10, - // Most of these cases assert on the status row alone; the ones that - // exercise the window override this. - stderr_rows: StderrRows::Off, - } -} - -/// A startup set over `joins`, plus the sender a test can feed stderr through. -/// -/// Callers that don't exercise the window drop the sender, which closes the -/// channel; the wait treats that as "no more lines" rather than an error. -fn startup_set( - joins: tokio::task::JoinSet>, - pending: Vec, -) -> (StartupSet, broadcast::Sender) { - let (tx, rx) = broadcast::channel(64); - - ( - StartupSet { - joins, - pending, - stderr: rx, - }, - tx, - ) -} - -#[tokio::test] -async fn await_mcp_servers_drains_all_startups() { - let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); - - let mut joins = tokio::task::JoinSet::new(); - joins.spawn(async { Ok(Startup::Ready(McpServerId::new("bookworm"))) }); - joins.spawn(async { Ok(Startup::Ready(McpServerId::new("grizzly"))) }); - let (startup, _lines) = startup_set(joins, vec![ - McpServerId::new("bookworm"), - McpServerId::new("grizzly"), - ]); - - let skipped = await_mcp_servers(startup, immediate_mcp_startup_config(), Arc::new(printer)) - .await - .expect("all startups succeed"); - - assert!(skipped.is_empty(), "no server was skipped"); -} - -#[tokio::test] -async fn await_mcp_servers_propagates_startup_error() { - let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); - - let mut joins = tokio::task::JoinSet::new(); - joins.spawn(async { Err(jp_mcp::Error::UnknownServer(McpServerId::new("bookworm"))) }); - let (startup, _lines) = startup_set(joins, vec![McpServerId::new("bookworm")]); - - let error = await_mcp_servers(startup, immediate_mcp_startup_config(), Arc::new(printer)) - .await - .expect_err("a failed required server must fail the wait"); - - assert_eq!(error.message.as_deref(), Some("MCP error")); -} - -#[tokio::test(flavor = "multi_thread")] -async fn await_mcp_servers_shows_and_clears_timer_line() { - let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); - let printer = Arc::new(printer.with_terminal(TerminalCapability::interactive(Some(80)))); - - // Hold the startup window open until the test releases it, so the timer - // is guaranteed to tick while the server is still "starting". - let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>(); - let mut joins = tokio::task::JoinSet::new(); - joins.spawn(async move { - release_rx.await.ok(); - Ok(Startup::Ready(McpServerId::new("bookworm"))) - }); - let (startup, _lines) = startup_set(joins, vec![McpServerId::new("bookworm")]); - - let wait = tokio::spawn(await_mcp_servers( - startup, - immediate_mcp_startup_config(), - printer.clone(), - )); - - // Let a few ticks land before releasing the startup. - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - release_tx.send(()).expect("wait task is still running"); - wait.await - .expect("task did not panic") - .expect("startup succeeds"); - printer.flush(); - - let chrome = err.lock(); - assert!( - chrome.contains("⏱ Starting MCP server bookworm…"), - "timer line should name the pending server.\nChrome:\n{chrome}" - ); - assert!( - chrome.ends_with("\r\x1b[K"), - "finishing the wait must leave the line cleared.\nChrome:\n{chrome}" - ); -} - -/// An `AppConfig` whose `search` tool is backed by the `bookworm` MCP server. -fn config_with_mcp_tool(enabled: bool) -> AppConfig { - let mut partial = AppConfig::new_test().to_partial(); - partial - .conversation - .tools - .tools - .insert("search".to_owned(), PartialToolConfig { - source: Some(ToolSource::Mcp { - server: "bookworm".to_owned(), - tool: None, - }), - enable: Some(PartialEnableConfig { - state: Some(enabled), - ..PartialEnableConfig::default() - }), - ..PartialToolConfig::default() - }); - - build(partial).expect("the fixture config resolves") -} - -#[test] -fn skipped_server_report_names_the_tools_that_went_with_it() { - let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); - - report_skipped_servers(&printer, &config_with_mcp_tool(true), &[McpServerId::new( - "bookworm", - )]); - printer.flush(); - - let chrome = err.lock(); - assert!( - chrome.contains("Optional MCP server 'bookworm' did not start"), - "the report must name the server.\nChrome:\n{chrome}" - ); - assert!( - chrome.contains("unavailable tools: search"), - "the report must name the tools that went with it.\nChrome:\n{chrome}" - ); - assert!( - chrome.contains("-v"), - "the report must point at where the reason lives.\nChrome:\n{chrome}" - ); -} - -#[test] -fn skipped_server_report_is_ndjson_under_json_format() { - let (printer, _out, err) = Printer::memory(OutputFormat::Json); - - report_skipped_servers(&printer, &config_with_mcp_tool(true), &[McpServerId::new( - "bookworm", - )]); - printer.flush(); - - let chrome = err.lock().clone(); - let parsed: serde_json::Value = - serde_json::from_str(chrome.trim()).expect("chrome is one NDJSON record"); - - assert_eq!(parsed["event"], "mcp_server_unavailable"); - assert_eq!(parsed["server"], "bookworm"); - assert_eq!(parsed["tools"][0], "search"); -} - -#[test] -fn skipped_server_report_skips_disabled_tools() { - let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); - - report_skipped_servers(&printer, &config_with_mcp_tool(false), &[McpServerId::new( - "bookworm", - )]); - printer.flush(); - - let chrome = err.lock(); - assert!( - chrome.contains("Optional MCP server 'bookworm' did not start"), - "the server is still reported.\nChrome:\n{chrome}" - ); - assert!( - !chrome.contains("unavailable tools"), - "a tool that was already off did not become unavailable.\nChrome:\n{chrome}" - ); -} - -/// A startup wait that shows two window rows above the status row. -fn windowed_mcp_startup_config() -> McpStartupConfig { - McpStartupConfig { - stderr_rows: StderrRows::Fixed(RowCount { rows: 2 }), - ..immediate_mcp_startup_config() - } -} - -#[tokio::test(flavor = "multi_thread")] -async fn await_mcp_servers_shows_server_stderr_while_it_starts() { - let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); - let printer = Arc::new( - printer.with_terminal(TerminalCapability::interactive(Some(80)).with_rows(Some(24))), - ); - - let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>(); - let mut joins = tokio::task::JoinSet::new(); - joins.spawn(async move { - release_rx.await.ok(); - Ok(Startup::Ready(McpServerId::new("bookworm"))) - }); - let (startup, lines) = startup_set(joins, vec![McpServerId::new("bookworm")]); - - let wait = tokio::spawn(await_mcp_servers( - startup, - windowed_mcp_startup_config(), - printer.clone(), - )); - - lines - .send((McpServerId::new("bookworm"), "Compiling serde".to_owned())) - .expect("the wait holds a receiver"); - wait_for_frame(&err, "Compiling serde").await; - - release_tx.send(()).expect("wait task is still running"); - wait.await - .expect("task did not panic") - .expect("startup succeeds"); - printer.flush(); - - let chrome = err.lock(); - assert!( - chrome.contains("⏱ Starting MCP server bookworm…"), - "the status row still names the pending server.\nChrome:\n{chrome}" - ); - assert!( - !chrome.contains("[bookworm]"), - "a single source renders verbatim, without a label.\nChrome:\n{chrome}" - ); -} - -#[tokio::test(flavor = "multi_thread")] -async fn window_lines_are_labelled_once_two_servers_contribute() { - let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); - let printer = Arc::new( - printer.with_terminal(TerminalCapability::interactive(Some(80)).with_rows(Some(24))), - ); - - let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>(); - let mut joins = tokio::task::JoinSet::new(); - joins.spawn(async move { - release_rx.await.ok(); - Ok(Startup::Ready(McpServerId::new("bookworm"))) - }); - let (startup, lines) = startup_set(joins, vec![ - McpServerId::new("bookworm"), - McpServerId::new("grizzly"), - ]); - - let wait = tokio::spawn(await_mcp_servers( - startup, - windowed_mcp_startup_config(), - printer.clone(), - )); - - // Interleaved output from two sources is worse than none unlabelled: it - // misattributes progress. - lines - .send((McpServerId::new("bookworm"), "Compiling serde".to_owned())) - .expect("the wait holds a receiver"); - lines - .send((McpServerId::new("grizzly"), "Compiling tantivy".to_owned())) - .expect("the wait holds a receiver"); - // Labelling only starts once the window holds two sources, so the first - // label appearing means both lines have landed. - wait_for_frame(&err, "[bookworm]").await; - - release_tx.send(()).expect("wait task is still running"); - wait.await - .expect("task did not panic") - .expect("startup succeeds"); - printer.flush(); - - // The label's own colour is `jp_printer`'s business; what matters here is - // that each line carries its source's name, padded to line up, and that the - // colour closes before the source's own text starts. - let chrome = err.lock(); - assert!( - chrome.contains("[bookworm]\x1b[39m Compiling serde"), - "the first source must be labelled.\nChrome:\n{chrome}" - ); - assert!( - chrome.contains("[grizzly ]\x1b[39m Compiling tantivy"), - "the second source must be labelled and aligned.\nChrome:\n{chrome}" - ); -} - -#[tokio::test] -async fn await_mcp_servers_reports_skipped_optional_servers() { - let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); - - let mut joins = tokio::task::JoinSet::new(); - joins.spawn(async { Ok(Startup::Skipped(McpServerId::new("bookworm"))) }); - joins.spawn(async { Ok(Startup::Ready(McpServerId::new("grizzly"))) }); - let (startup, _lines) = startup_set(joins, vec![ - McpServerId::new("bookworm"), - McpServerId::new("grizzly"), - ]); - - let skipped = await_mcp_servers(startup, immediate_mcp_startup_config(), Arc::new(printer)) - .await - .expect("an optional failure completes the wait"); - - assert_eq!(skipped, vec![McpServerId::new("bookworm")]); -} - -/// Poll `err` until `needle` appears, failing after a hard timeout. -/// -/// Synchronizes on the rendered output instead of a fixed sleep: the timer -/// writes frames from its own task, so tests wait for the frame to land rather -/// than guessing how long that takes. -async fn wait_for_frame(err: &SharedBuffer, needle: &str) { - tokio::time::timeout(std::time::Duration::from_secs(5), async { - while !err.lock().contains(needle) { - tokio::time::sleep(std::time::Duration::from_millis(5)).await; - } - }) - .await - .unwrap_or_else(|_| panic!("frame {needle:?} never rendered")); -} - -/// Drives the aggregate redraw: two servers start, one finishes while the other -/// is still pending, then the second finishes. -/// The line must go from both names, to the survivor alone, to cleared. -#[tokio::test(flavor = "multi_thread")] -async fn await_mcp_servers_redraws_as_servers_finish() { - let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); - let printer = Arc::new(printer.with_terminal(TerminalCapability::interactive(Some(80)))); - - // Two independently-released tasks: releasing `bookworm` first makes - // `grizzly` the deterministic survivor of the mid-drain redraw. - let (bookworm_tx, bookworm_rx) = tokio::sync::oneshot::channel::<()>(); - let (grizzly_tx, grizzly_rx) = tokio::sync::oneshot::channel::<()>(); - let mut joins = tokio::task::JoinSet::new(); - joins.spawn(async move { - bookworm_rx.await.ok(); - Ok(Startup::Ready(McpServerId::new("bookworm"))) - }); - joins.spawn(async move { - grizzly_rx.await.ok(); - Ok(Startup::Ready(McpServerId::new("grizzly"))) - }); - let (startup, _lines) = startup_set(joins, vec![ - McpServerId::new("bookworm"), - McpServerId::new("grizzly"), - ]); - - let wait = tokio::spawn(await_mcp_servers( - startup, - immediate_mcp_startup_config(), - printer.clone(), - )); - - // Advance on the rendered frames, not the clock: wait until each frame is - // actually in the buffer before releasing the next server, so a slow timer - // task can't make the release outrun the redraw it's supposed to observe. - wait_for_frame(&err, "2 MCP servers (bookworm, grizzly)").await; - bookworm_tx.send(()).expect("wait task is still running"); - wait_for_frame(&err, "MCP server grizzly…").await; - grizzly_tx.send(()).expect("wait task is still running"); - wait.await - .expect("task did not panic") - .expect("all startups succeed"); - printer.flush(); - - let chrome = err.lock(); - let both = chrome - .find("2 MCP servers (bookworm, grizzly)") - .expect("the aggregate two-server frame must render first"); - let survivor = chrome - .find("MCP server grizzly…") - .expect("the survivor-only frame must render after bookworm finishes"); - assert!( - both < survivor, - "the two-server frame must precede the survivor-only frame.\nChrome:\n{chrome}" - ); - assert!( - !chrome.contains("MCP server bookworm…"), - "bookworm was never the sole pending server; it must not render alone.\nChrome:\n{chrome}" - ); - assert!( - chrome.ends_with("\r\x1b[K"), - "finishing the wait must leave the line cleared.\nChrome:\n{chrome}" - ); -} - -#[test] -fn mcp_startup_line_renders_full_when_it_fits() { - assert_eq!( - mcp_startup_line(4.2, Some("MCP server bookworm"), Some(80)), - "⏱ Starting MCP server bookworm… 4.2s" - ); - // Unknown width leaves the line unbounded. - assert_eq!( - mcp_startup_line(4.2, Some("MCP server bookworm"), None), - "⏱ Starting MCP server bookworm… 4.2s" - ); -} - -// A long server list forced to truncate must keep the elapsed-time suffix: the -// whole point of the line is the moving timer, so truncation has to fall on the -// server list, not the `Ns` tail. Testing the pure formatter at a fixed `secs` -// pins the invariant without depending on when the timer task first ticks. -#[test] -fn mcp_startup_line_truncation_preserves_timer_suffix() { - let long = "MCP server bookworm-with-a-very-long-descriptive-server-name"; - let line = mcp_startup_line(12.3, Some(long), Some(30)); - - assert!(line.ends_with(" 12.3s"), "suffix must survive: {line:?}"); - assert!(line.contains('…'), "server list must truncate: {line:?}"); - assert!(display_width(&line) <= 30, "must fit width: {line:?}"); -} - -// A terminal too narrow for even the prefix and suffix still keeps a moving -// timer rather than a static stub. -#[test] -fn mcp_startup_line_ultra_narrow_keeps_bounded_timer() { - let line = mcp_startup_line(7.0, Some("MCP server bookworm"), Some(6)); - - assert!(display_width(&line) <= 6, "must fit width: {line:?}"); - assert!(line.contains("7.0s"), "timer must survive: {line:?}"); -} - #[test] fn arg_file_path_recognizes_sigil() { assert_eq!(arg_file_path("@notes.md"), Some("notes.md")); @@ -4049,3 +3527,176 @@ fn read_arg_file_error_names_the_path() { "unexpected message: {error}" ); } + +/// A partial naming `model` as the turn's model. +fn partial_with_model(model: PartialModelIdOrAliasConfig) -> PartialAppConfig { + let mut partial = PartialAppConfig::default(); + partial.assistant.model.id = model; + partial +} + +fn model_id(id: &str) -> PartialModelIdOrAliasConfig { + PartialModelIdOrAliasConfig::Id(id.parse().expect("a valid model id")) +} + +fn alias(name: &str) -> PartialModelIdOrAliasConfig { + PartialModelIdOrAliasConfig::Alias(name.to_owned()) +} + +/// The chain lands on the provider of the model serving the turn, and nowhere +/// else. +#[test] +fn test_auth_writes_the_chain_to_the_turns_provider() { + let mut partial = partial_with_model(model_id("openai/gpt-5.6-luna")); + let auth = vec![AuthEntry::Subscription(Some("personal".to_owned()))]; + + apply_auth(&mut partial, &auth, None).unwrap(); + + assert_eq!(partial.providers.llm.openai.auth.as_ref(), Some(&auth)); + assert_eq!(partial.providers.llm.anthropic.auth, None); + assert_eq!(partial.providers.llm.cerebras.auth, None); +} + +/// A whole chain is written, not only its first entry. +#[test] +fn test_auth_writes_every_entry_in_order() { + let mut partial = partial_with_model(model_id("anthropic/claude-haiku-4-5")); + let auth = vec![ + AuthEntry::Named("personal".to_owned()), + AuthEntry::ApiKey(None), + ]; + + apply_auth(&mut partial, &auth, None).unwrap(); + + assert_eq!(partial.providers.llm.anthropic.auth.as_ref(), Some(&auth)); +} + +/// An alias is followed to the provider it stands for. +#[test] +fn test_auth_follows_an_alias_to_its_provider() { + let mut partial = partial_with_model(alias("luna")); + partial + .providers + .llm + .aliases + .insert("luna".to_owned(), model_id("openai/gpt-5.6-luna")); + + apply_auth(&mut partial, &[AuthEntry::ApiKey(None)], None).unwrap(); + + assert_eq!( + partial.providers.llm.openai.auth, + Some(vec![AuthEntry::ApiKey(None)]) + ); +} + +/// An alias naming another alias resolves through to the model id. +#[test] +fn test_auth_follows_a_chain_of_aliases() { + let mut partial = partial_with_model(alias("fast")); + partial + .providers + .llm + .aliases + .insert("fast".to_owned(), alias("luna")); + partial + .providers + .llm + .aliases + .insert("luna".to_owned(), model_id("openai/gpt-5.6-luna")); + + apply_auth(&mut partial, &[AuthEntry::ApiKey(None)], None).unwrap(); + + assert!(partial.providers.llm.openai.auth.is_some()); +} + +/// An alias pointing at itself reports rather than recursing forever. +#[test] +fn test_auth_gives_up_on_an_alias_cycle() { + let mut partial = partial_with_model(alias("a")); + partial + .providers + .llm + .aliases + .insert("a".to_owned(), alias("b")); + partial + .providers + .llm + .aliases + .insert("b".to_owned(), alias("a")); + + let error = apply_auth(&mut partial, &[AuthEntry::ApiKey(None)], None).unwrap_err(); + + assert!( + error.to_string().contains("--auth needs to know"), + "{error}" + ); +} + +/// The provider comes from the config layers when the CLI names no model. +#[test] +fn test_auth_reads_the_provider_from_the_merged_config() { + let mut partial = PartialAppConfig::default(); + let merged = partial_with_model(model_id("cerebras/gpt-oss-120b")); + + apply_auth(&mut partial, &[AuthEntry::ApiKey(None)], Some(&merged)).unwrap(); + + assert!(partial.providers.llm.cerebras.auth.is_some()); +} + +/// The CLI's own model wins over the one the config layers settled on. +#[test] +fn test_auth_prefers_the_models_named_on_the_command_line() { + let mut partial = partial_with_model(model_id("openai/gpt-5.6-luna")); + let merged = partial_with_model(model_id("anthropic/claude-haiku-4-5")); + + apply_auth(&mut partial, &[AuthEntry::ApiKey(None)], Some(&merged)).unwrap(); + + assert!(partial.providers.llm.openai.auth.is_some()); + assert_eq!(partial.providers.llm.anthropic.auth, None); +} + +/// An unresolvable provider names the `--cfg` form that would work, rather than +/// writing the chain somewhere it would do nothing. +#[test] +fn test_auth_without_a_provider_names_the_alternative() { + let mut partial = PartialAppConfig::default(); + let auth = vec![ + AuthEntry::Subscription(Some("personal".to_owned())), + AuthEntry::ApiKey(None), + ]; + + let error = apply_auth(&mut partial, &auth, None).unwrap_err(); + let message = error.to_string(); + + assert!(message.contains("--model /"), "{message}"); + assert!( + message.contains("providers.llm..auth=subscription:personal,api_key"), + "{message}" + ); +} + +/// A provider reached over a local socket has no credential to choose. +#[test] +fn test_auth_is_refused_for_a_provider_without_credentials() { + for model in ["llamacpp/qwen3", "ollama/qwen3"] { + let mut partial = partial_with_model(model_id(model)); + + let error = apply_auth(&mut partial, &[AuthEntry::ApiKey(None)], None).unwrap_err(); + + assert!( + error.to_string().contains("needs no credential"), + "{model}: {error}" + ); + } +} + +/// Without the flag, nothing is written and no provider is looked for. +#[test] +fn test_auth_is_a_no_op_when_the_flag_is_absent() { + let mut partial = PartialAppConfig::default(); + + apply_auth(&mut partial, &[], None).unwrap(); + + assert_eq!(partial.providers.llm.openai.auth, None); + assert_eq!(partial.providers.llm.anthropic.auth, None); +} diff --git a/crates/jp_cli/src/error.rs b/crates/jp_cli/src/error.rs index a0ec92357..163014305 100644 --- a/crates/jp_cli/src/error.rs +++ b/crates/jp_cli/src/error.rs @@ -2,9 +2,10 @@ use std::io; use camino::Utf8PathBuf; use jp_conversation::ConversationId; +use jp_mcp::server::http::EndpointError; use url::Url; -use crate::cmd; +use crate::{cmd, cmd::query::tool::executor::ExecutorError}; pub(crate) type Result = std::result::Result; @@ -65,6 +66,13 @@ pub(crate) enum Error { #[error("MCP error")] Mcp(#[from] jp_mcp::Error), + #[error(transparent)] + McpEndpoint(#[from] EndpointError), + + /// The Host could not establish the provider's tool-dispatch contract. + #[error("MCP Host control failed: {0}")] + McpHost(#[source] ExecutorError), + #[error("LLM error")] Llm(#[from] jp_llm::Error), @@ -118,7 +126,7 @@ pub(crate) enum Error { Url(#[from] url::ParseError), #[error("Tool error")] - Tool(#[from] jp_llm::ToolError), + Tool(#[from] jp_tool::Error), #[error("Syntax highlighting error")] SyntaxHighlight(#[from] syntect::Error), diff --git a/crates/jp_cli/src/render/tool.rs b/crates/jp_cli/src/render/tool.rs index 1c71dd8d9..536bbae41 100644 --- a/crates/jp_cli/src/render/tool.rs +++ b/crates/jp_cli/src/render/tool.rs @@ -11,22 +11,16 @@ use std::{ time::Duration, }; -use camino::{Utf8Path, Utf8PathBuf}; use crossterm::style::Stylize as _; use jp_config::{ - conversation::tool::{ - CommandConfig, - style::{InlineResults, LinkStyle, ParametersStyle, TruncateLines}, - }, + conversation::tool::style::{InlineResults, LinkStyle, ParametersStyle, TruncateLines}, style::{StyleConfig, stderr_rows::StderrRows}, }; use jp_conversation::event::ToolCallResponse; -use jp_llm::{CommandResult, run_tool_command, tool::InvocationContext}; use jp_md::format::Formatter; use jp_printer::{ErrChannel, LineSink, OutputLines, RegionStyle, StatusRegion}; use jp_term::{background::DefaultBackground, osc::hyperlink, shade::ShadedWriter}; use serde_json::{Map, Value}; -use tokio_util::sync::CancellationToken; use tracing::warn; /// Map the `stderr_rows` config key onto the printer's window budget. @@ -87,11 +81,6 @@ pub enum RenderOutcome { pub struct ToolRenderer { channel: ErrChannel, config: StyleConfig, - root: Utf8PathBuf, - - /// Workspace and conversation identity, forwarded to custom argument - /// formatter commands. - invocation: InvocationContext, /// Markdown formatter used for syntax highlighting code blocks in tool /// results. @@ -142,12 +131,7 @@ pub struct ToolRenderer { } impl ToolRenderer { - pub fn new( - channel: ErrChannel, - config: StyleConfig, - root: Utf8PathBuf, - invocation: InvocationContext, - ) -> Self { + pub fn new(channel: ErrChannel, config: StyleConfig) -> Self { let formatter = Formatter::new().theme(if channel.pretty_printing_enabled() { config.markdown.theme.as_deref() } else { @@ -157,8 +141,6 @@ impl ToolRenderer { Self { channel, config, - root, - invocation, formatter, pending: Vec::new(), preparing: StatusRegion::inert(), @@ -269,57 +251,40 @@ impl ToolRenderer { }); } - /// Renders a tool call with all styles, printing header and arguments - /// atomically. - /// - /// For non-Custom styles: prints the header with inline-formatted arguments - /// in a single write. + /// Renders an approved tool call, printing header and arguments atomically. /// - /// For Custom style: runs the custom formatter command first, then prints - /// the header followed by the formatted output. - /// If the custom formatter fails, nothing is printed and - /// [`RenderOutcome::Suppressed`] is returned. + /// Prints the header with inline-formatted arguments in a single write, and + /// returns `Rendered { content: None }`: the built-in styles print their + /// arguments inline rather than producing content a caller persists. /// - /// On success, returns `Rendered { content }` where `content` is the - /// custom-formatted output (if any) so the caller can persist it for - /// replay. + /// A `Custom` style is rendered by [`render_custom_result`] instead, from + /// output the execution service produced. /// - /// `name` is what the assistant called and what the header shows. - /// `invoked_name` is what the tool's implementation is called, which a - /// `source` override can make different, and is what a custom formatter - /// receives. - pub async fn render_approved( + /// [`render_custom_result`]: Self::render_custom_result + pub fn render_approved( &self, name: &str, - invoked_name: &str, arguments: &Map, style: &ParametersStyle, ) -> RenderOutcome { - if let ParametersStyle::Custom(cmd_config) = style { - let cmd = cmd_config.clone().command(); - self.render_custom_tool_call(name, invoked_name, arguments, cmd) - .await - } else { - self.render_tool_call(name, arguments, style); - RenderOutcome::Rendered { content: None } - } + self.render_tool_call(name, arguments, style); + RenderOutcome::Rendered { content: None } } - /// Renders a Custom-style tool call: header + custom formatted output. + /// Render custom arguments already formatted by the execution service. /// - /// Runs the custom formatter command first. - /// If it succeeds, prints the "Calling tool X" header followed by the - /// formatted output. - /// If it fails, nothing is printed — the tool call is suppressed from the - /// display. - async fn render_custom_tool_call( + /// Prints the "Calling tool X" header followed by the formatted output. + /// A formatter that failed prints nothing and returns + /// [`RenderOutcome::Suppressed`], so a broken formatter does not show a + /// half-rendered call. + /// + /// The returned content is what the caller persists for replay. + pub(crate) fn render_custom_result( &self, name: &str, - invoked_name: &str, - arguments: &Map, - cmd: CommandConfig, + result: Result, ) -> RenderOutcome { - match format_args_custom(invoked_name, arguments, cmd, &self.root, &self.invocation).await { + match result { Ok(content) if !content.is_empty() => { let styled_name = name.yellow().bold(); self.write_chrome(self.current_region.as_ref(), |w| { @@ -423,7 +388,7 @@ impl ToolRenderer { /// - `response` - The tool call response containing the result /// - `inline_results` - How to display inline results (Off, Full, Truncate) /// - `results_file_link` - How to display file links (Off, Full, Osc8) - #[allow(clippy::too_many_lines)] + #[expect(clippy::too_many_lines)] pub fn render_result( &self, response: &ToolCallResponse, @@ -608,7 +573,7 @@ impl ToolRenderer { } } - /// Completes a tool call and removes it from the temp line. + /// End a call's pending-arguments display, including an abandoned call. /// /// This only handles the rewritable temp-line display. /// The permanent "Calling tool ..." header is printed later by @@ -618,10 +583,8 @@ impl ToolRenderer { pub fn complete(&mut self, id: &str) { self.pending.retain(|t| t.id != id); - // The completed tool's permanent header is rendered immediately after - // this returns and uses the currently-active region, so realign that - // region to this tool's captured one (a no-op for a single tool, but - // correct when parallel tools sit in different regions). + // A prepared call's permanent header uses its captured region, which + // can differ from the other calls still on the preparing row. self.current_region = self.regions.get(id).cloned(); if self.pending.is_empty() { @@ -807,108 +770,6 @@ fn format_args_json(arguments: Map) -> String { format!(" with arguments:\n\n```json\n{pretty}\n```") } -/// Runs a custom arguments formatter command and returns the content. -/// -/// `tool_name` is the name the tool is invoked under, which is the name its own -/// implementation answers to rather than the key the assistant called. -async fn format_args_custom( - tool_name: &str, - arguments: &Map, - cmd: CommandConfig, - root: &Utf8Path, - invocation: &InvocationContext, -) -> Result { - let ctx = serde_json::json!({ - "tool": { - "name": tool_name, - "arguments": arguments, - }, - "context": { - "action": jp_tool::Action::FormatArguments, - "root": root, - "workspace_id": &invocation.workspace_id, - "conversation_id": &invocation.conversation_id, - }, - }); - - let result = run_tool_command(cmd.clone(), ctx, root, CancellationToken::new(), None) - .await - .map_err(|e| { - warn!( - command = %cmd, - error = %e, - "Custom parameters formatter failed" - ); - format!("Custom parameters formatter '{cmd}' failed: {e}") - })?; - - match result { - CommandResult::Success(content) => Ok(content.trim().to_owned()), - CommandResult::TransientError { message, trace } => { - let detail = CommandResult::format_error(&message, &trace); - warn!( - command = %cmd, - error = %detail, - "Custom parameters formatter returned error" - ); - Err(detail) - } - CommandResult::FatalError(raw) => { - warn!( - command = %cmd, - "Custom parameters formatter returned fatal error" - ); - Err(raw) - } - CommandResult::NeedsInput(_) => { - warn!( - command = %cmd, - "Custom parameters formatter returned NeedsInput" - ); - Err(format!( - "Custom parameters formatter '{cmd}' returned unexpected NeedsInput" - )) - } - CommandResult::Cancelled => Ok(String::new()), - CommandResult::InvalidInquiry { question_id } => { - warn!( - command = %cmd, - question_id = %question_id, - "Custom parameters formatter returned an invalid inquiry" - ); - Err(format!( - "Custom parameters formatter '{cmd}' produced an invalid inquiry (question id \ - '{question_id}')" - )) - } - CommandResult::MalformedInquiry { detail } => { - warn!( - command = %cmd, - %detail, - "Custom parameters formatter returned a malformed inquiry" - ); - Err(format!( - "Custom parameters formatter '{cmd}' produced a malformed inquiry: {detail}" - )) - } - CommandResult::RawOutput { - stdout, - success: true, - .. - } => Ok(stdout.trim().to_owned()), - CommandResult::RawOutput { stderr, .. } => { - warn!( - command = %cmd, - error = %stderr, - "Custom parameters formatter failed" - ); - Err(format!( - "Custom parameters formatter '{cmd}' failed: {stderr}" - )) - } - } -} - #[cfg(test)] #[path = "tool_tests.rs"] mod tests; diff --git a/crates/jp_cli/src/render/tool_tests.rs b/crates/jp_cli/src/render/tool_tests.rs index 36e7c678d..0321f2de7 100644 --- a/crates/jp_cli/src/render/tool_tests.rs +++ b/crates/jp_cli/src/render/tool_tests.rs @@ -1,6 +1,5 @@ use std::sync::Arc; -use camino_tempfile::Utf8TempDir; use jp_config::{ AppConfig, conversation::tool::{CommandConfigOrString, style::ParametersStyle}, @@ -88,12 +87,7 @@ fn create_renderer() -> (ToolRenderer, SharedBuffer, SharedBuffer) { let (printer, out, err) = Printer::memory(OutputFormat::TextPretty); let mut config = AppConfig::new_test().style; config.tool_call.show = true; - let renderer = ToolRenderer::new( - ErrChannel::new(Arc::new(printer)), - config, - "/tmp".into(), - jp_llm::tool::InvocationContext::default(), - ); + let renderer = ToolRenderer::new(ErrChannel::new(Arc::new(printer)), config); (renderer, err, out) } @@ -113,12 +107,7 @@ fn create_renderer_with_show(show: bool) -> (ToolRenderer, SharedBuffer) { config.tool_call.progress.stderr_rows = StderrRows::Fixed(RowCount { rows: 2 }); let printer = printer.with_terminal(TerminalCapability::interactive(Some(80)).with_rows(Some(24))); - let renderer = ToolRenderer::new( - ErrChannel::new(Arc::new(printer)), - config, - "/tmp".into(), - jp_llm::tool::InvocationContext::default(), - ); + let renderer = ToolRenderer::new(ErrChannel::new(Arc::new(printer)), config); (renderer, err) } @@ -183,25 +172,13 @@ fn test_render_tool_call_custom_does_not_run_command() { insta::assert_snapshot!(output); } -#[tokio::test] -async fn test_render_custom_arguments_after_approval() { - let root = Utf8TempDir::new().unwrap(); +#[test] +fn test_render_custom_result_after_approval() { let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); let config = AppConfig::new_test().style; - let renderer = ToolRenderer::new( - ErrChannel::new(Arc::new(printer)), - config, - root.path().to_owned(), - jp_llm::tool::InvocationContext::default(), - ); - - let mut args = Map::new(); - args.insert("host".into(), Value::String("myhost".into())); - let style = ParametersStyle::Custom(CommandConfigOrString::String("echo custom-output".into())); + let renderer = ToolRenderer::new(ErrChannel::new(Arc::new(printer)), config); - let outcome = renderer - .render_approved("ssh_run", "ssh_run", &args, &style) - .await; + let outcome = renderer.render_custom_result("ssh_run", Ok("custom-output".into())); assert!(matches!(outcome, RenderOutcome::Rendered { content: Some(_) @@ -216,6 +193,24 @@ async fn test_render_custom_arguments_after_approval() { assert_eq!(output, "Calling tool ssh_run\n\ncustom-output\n"); } +#[test] +fn test_render_custom_result_suppresses_a_failed_formatter() { + let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); + let renderer = ToolRenderer::new( + ErrChannel::new(Arc::new(printer)), + AppConfig::new_test().style, + ); + + let outcome = renderer.render_custom_result("ssh_run", Err("formatter exploded".into())); + + assert!( + matches!(outcome, RenderOutcome::Suppressed { ref error } if error == "formatter exploded") + ); + renderer.channel.flush(); + // A broken formatter must not leave a header with nothing under it. + assert_eq!(strip_ansi(&err.lock()), ""); +} + #[test] fn test_consecutive_plain_headers_are_grouped() { // Plain (non-custom) headers carry no owed separator, so a batch of tool @@ -405,12 +400,7 @@ fn progress_window_is_off_without_print_stderr() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let mut config = AppConfig::new_test().style; config.tool_call.progress.stderr_rows = StderrRows::Off; - let renderer = ToolRenderer::new( - ErrChannel::new(Arc::new(printer)), - config, - "/tmp".into(), - jp_llm::tool::InvocationContext::default(), - ); + let renderer = ToolRenderer::new(ErrChannel::new(Arc::new(printer)), config); assert!(renderer.progress_source("cargo_test").is_none()); } @@ -485,6 +475,35 @@ fn test_register_duplicate_ignored() { assert_eq!(renderer.pending.len(), 1); } +#[test] +fn replacing_an_abandoned_call_does_not_collapse_distinct_calls() { + let (mut renderer, _out) = create_renderer_with_show(true); + renderer.register("abandoned", "fs_create_file"); + renderer.complete("abandoned"); + renderer.register("replacement", "fs_create_file"); + renderer.register("replacement", "fs_create_file"); + assert_eq!( + renderer + .pending + .iter() + .map(|call| (call.id.as_str(), call.name.as_str())) + .collect::>(), + vec![("replacement", "fs_create_file")] + ); + renderer.register("another", "fs_create_file"); + assert_eq!( + renderer + .pending + .iter() + .map(|call| (call.id.as_str(), call.name.as_str())) + .collect::>(), + vec![ + ("replacement", "fs_create_file"), + ("another", "fs_create_file") + ] + ); +} + #[test] fn test_complete_removes_from_pending() { let (mut renderer, _out) = create_renderer_with_show(true); @@ -526,12 +545,7 @@ fn test_completing_one_pending_tool_does_not_collide_with_header() { // Disable the animated suffix so `register` doesn't spawn a timer task // (this is a sync test with no tokio runtime). config.tool_call.preparing.show = false; - let mut renderer = ToolRenderer::new( - ErrChannel::new(Arc::new(printer)), - config, - "/tmp".into(), - jp_llm::tool::InvocationContext::default(), - ); + let mut renderer = ToolRenderer::new(ErrChannel::new(Arc::new(printer)), config); renderer.register("id1", "fs_read_file"); renderer.register("id2", "fs_read_file"); @@ -602,12 +616,7 @@ fn test_preparing_row_carries_the_elapsed_time() { #[test] fn test_show_false_suppresses_preparing_output() { let config = AppConfig::new_test().style; - let mut renderer = ToolRenderer::new( - ErrChannel::new(Arc::new(Printer::sink())), - config, - "/tmp".into(), - jp_llm::tool::InvocationContext::default(), - ); + let mut renderer = ToolRenderer::new(ErrChannel::new(Arc::new(Printer::sink())), config); renderer.register("id1", "tool_a"); renderer.complete("id1"); @@ -618,12 +627,7 @@ fn test_show_false_suppresses_preparing_output() { #[test] fn test_tool_call_show_false_suppresses_output() { let config = AppConfig::new_test().style; - let renderer = ToolRenderer::new( - ErrChannel::new(Arc::new(Printer::sink())), - config, - "/tmp".into(), - jp_llm::tool::InvocationContext::default(), - ); + let renderer = ToolRenderer::new(ErrChannel::new(Arc::new(Printer::sink())), config); let mut args = Map::new(); args.insert("key".into(), Value::String("value".into())); @@ -667,48 +671,6 @@ fn test_format_args_custom_returns_empty() { assert_eq!(result, ""); } -#[tokio::test] -async fn test_format_custom_content_returns_raw_content() { - let root = Utf8TempDir::new().unwrap(); - let mut args = Map::new(); - args.insert("key".into(), Value::String("value".into())); - let cmd = CommandConfigOrString::String("echo hello-world".into()).command(); - let result = format_args_custom( - "my_tool", - &args, - cmd, - root.path(), - &jp_llm::tool::InvocationContext::default(), - ) - .await - .unwrap(); - assert_eq!(result, "hello-world"); -} - -/// Regression: the `format_arguments` path must surface the invocation's -/// workspace and conversation IDs to a custom formatter command via -/// `context.workspace_id` and `context.conversation_id`. -/// A non-empty `InvocationContext` pins the wiring — the other tests pass the -/// empty default, which would still pass if the fields were dropped or wired to -/// empty strings. -#[tokio::test] -async fn test_format_args_custom_exposes_invocation_ids() { - let root = Utf8TempDir::new().unwrap(); - let args = Map::new(); - let cmd = CommandConfigOrString::String( - "echo {{context.workspace_id}}/{{context.conversation_id}}".into(), - ) - .command(); - let invocation = jp_llm::tool::InvocationContext { - workspace_id: "ws-abc".into(), - conversation_id: "conv-xyz".into(), - }; - let result = format_args_custom("my_tool", &args, cmd, root.path(), &invocation) - .await - .unwrap(); - assert_eq!(result, "ws-abc/conv-xyz"); -} - #[test] fn test_format_args_hides_empty_object_value() { let mut args = Map::new(); diff --git a/crates/jp_cli/src/render/turn.rs b/crates/jp_cli/src/render/turn.rs index 768e6a631..92f3cf7f8 100644 --- a/crates/jp_cli/src/render/turn.rs +++ b/crates/jp_cli/src/render/turn.rs @@ -8,7 +8,6 @@ use std::{collections::HashMap, sync::Arc}; -use camino::Utf8PathBuf; use chrono::Utc; use jp_config::{ PartialAppConfig, @@ -23,7 +22,6 @@ use jp_conversation::{ EventKind, stream::{TurnOrigin, turn_iter::Turn}, }; -use jp_llm::tool::InvocationContext; use jp_printer::{ErrChannel, Printer}; use tracing::warn; @@ -78,9 +76,7 @@ impl StyleOverlay { pub struct TurnRenderer { // Stable params for rebuilding sub-renderers. printer: Arc, - root: Utf8PathBuf, source: ConfigSource, - invocation: InvocationContext, view: TurnView, tool: ToolRenderer, @@ -113,9 +109,7 @@ impl TurnRenderer { mut tools_config: ToolsConfig, assistant_name: Option, model_id: Option, - root: Utf8PathBuf, source: ConfigSource, - invocation: InvocationContext, style_overlay: Option, ) -> Self { if let Some(overlay) = &style_overlay { @@ -130,18 +124,11 @@ impl TurnRenderer { RenderFlow::Replay, ); let tool_chrome_shown = style.tool_call.show; - let tool = ToolRenderer::new( - ErrChannel::new(printer.clone()), - style, - root.clone(), - invocation.clone(), - ); + let tool = ToolRenderer::new(ErrChannel::new(printer.clone()), style); view.set_tool_separator(tool.separator_flag()); Self { printer, - root, source, - invocation, view, tool, tools_config, @@ -290,12 +277,7 @@ impl TurnRenderer { assistant_name, model_id, ); - self.tool = ToolRenderer::new( - ErrChannel::new(self.printer.clone()), - style, - self.root.clone(), - self.invocation.clone(), - ); + self.tool = ToolRenderer::new(ErrChannel::new(self.printer.clone()), style); self.view.set_tool_separator(self.tool.separator_flag()); self.tools_config = tools_config; } diff --git a/crates/jp_config/src/providers/llm/anthropic.rs b/crates/jp_config/src/providers/llm/anthropic.rs index df77258e0..33ece9b61 100644 --- a/crates/jp_config/src/providers/llm/anthropic.rs +++ b/crates/jp_config/src/providers/llm/anthropic.rs @@ -1,6 +1,7 @@ -//! Anthropic API configuration. +//! Anthropic provider configuration. -use schematic::{Config, ConfigError}; +use schematic::{Config, ConfigEnum, ConfigError}; +use serde::{Deserialize, Serialize}; // Re-exported so `providers.llm.anthropic`'s own chain type is reachable // alongside its config, though the grammar itself is shared. @@ -18,7 +19,7 @@ use crate::{ /// The configuration path the credential chain lives at. const AUTH_KEY: &str = "providers.llm.anthropic.auth"; -/// Anthropic API configuration. +/// Anthropic provider configuration. #[derive(Debug, Clone, PartialEq, Config)] #[config(rename_all = "snake_case")] pub struct AnthropicConfig { @@ -31,9 +32,12 @@ pub struct AnthropicConfig { /// - `api_key`: Metered billing, using the key `api_key_env` names. /// - `api_key:`: Metered billing with the named key, when /// `api_key_env` maps several. - /// - `subscription`: A plan's allowance, using the sole stored credential. - /// Log in with `jp provider llm auth login anthropic`. - /// - `subscription:`: The named stored credential. + /// - `subscription`: A plan's allowance, using Claude Code's active login + /// with `subscription_flow = "acp"`, or the sole JP-stored credential + /// with `subscription_flow = "direct"`. + /// - `subscription:`: The named JP-stored credential, available with + /// `subscription_flow = "direct"`. + /// ACP does not map credential names. /// /// `api` and `sub` are accepted as shorthand for the two kinds. /// Names are case-sensitive. @@ -47,11 +51,27 @@ pub struct AnthropicConfig { /// /// ```toml /// [providers.llm.anthropic] - /// auth = ["subscription:personal", "subscription:work", "api_key"] + /// auth = ["subscription", "api_key"] /// ``` #[setting(default = vec![AuthEntry::ApiKey(None)])] pub auth: Vec, + /// How subscription requests reach Anthropic. + /// + /// Defaults to `acp`: use `claude-agent-acp` and Claude Code's active + /// subscription login. + /// Install `@agentclientprotocol/claude-agent-acp@0.76.0` and sign in with + /// `claude-agent-acp --cli auth login --claudeai`. + /// Named JP subscription credentials are not mapped to this login. + /// + /// Set to `direct` to use JP-stored subscription credentials through direct + /// HTTP requests. + /// This is an explicit opt-in to that flow's account-policy risk. + /// JP never falls back from `acp` to `direct` automatically. + /// API-key entries are unaffected and require no external runtime. + #[setting(default)] + pub subscription_flow: SubscriptionFlow, + /// Environment variable that contains the API key. /// /// A map names several keys, each selectable from the `auth` chain as @@ -107,6 +127,7 @@ impl AssignKeyValue for PartialAnthropicConfig { "" => kv.try_merge_object(self)?, "api_key_env" => self.api_key_env = kv.try_some_object_or_from_str()?, "base_url" => self.base_url = kv.try_some_string()?, + "subscription_flow" => self.subscription_flow = kv.try_some_object_or_from_str()?, "chain_on_max_tokens" => self.chain_on_max_tokens = kv.try_some_bool()?, _ if kv.p("auth") => { kv.try_some_vec(&mut self.auth, |kv| match kv.value.into_value() { @@ -128,6 +149,7 @@ impl PartialConfigDelta for PartialAnthropicConfig { fn delta(&self, next: Self) -> Self { Self { auth: delta_opt(self.auth.as_ref(), next.auth), + subscription_flow: delta_opt(self.subscription_flow.as_ref(), next.subscription_flow), api_key_env: delta_opt(self.api_key_env.as_ref(), next.api_key_env), base_url: delta_opt(self.base_url.as_ref(), next.base_url), chain_on_max_tokens: delta_opt( @@ -148,6 +170,7 @@ impl FillDefaults for PartialAnthropicConfig { fn fill_from(self, defaults: Self) -> Self { Self { auth: self.auth.or(defaults.auth), + subscription_flow: self.subscription_flow.or(defaults.subscription_flow), api_key_env: self.api_key_env.or(defaults.api_key_env), base_url: self.base_url.or(defaults.base_url), chain_on_max_tokens: self.chain_on_max_tokens.or(defaults.chain_on_max_tokens), @@ -162,6 +185,7 @@ impl ToPartial for AnthropicConfig { Self::Partial { auth: partial_opt(&self.auth, defaults.auth), + subscription_flow: partial_opt(&self.subscription_flow, defaults.subscription_flow), api_key_env: partial_opt(&self.api_key_env, defaults.api_key_env), base_url: partial_opt(&self.base_url, defaults.base_url), chain_on_max_tokens: partial_opt( @@ -176,6 +200,17 @@ impl ToPartial for AnthropicConfig { } } +/// The implementation used for subscription authentication entries. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, ConfigEnum)] +#[serde(rename_all = "snake_case")] +pub enum SubscriptionFlow { + /// Claude Code's active subscription login through the ACP adapter. + #[default] + Acp, + /// Direct HTTP requests authenticated with JP-stored subscription tokens. + Direct, +} + #[cfg(test)] #[path = "anthropic_tests.rs"] mod tests; diff --git a/crates/jp_config/src/providers/llm/anthropic_tests.rs b/crates/jp_config/src/providers/llm/anthropic_tests.rs index f83ae2f8e..f3302f26b 100644 --- a/crates/jp_config/src/providers/llm/anthropic_tests.rs +++ b/crates/jp_config/src/providers/llm/anthropic_tests.rs @@ -4,6 +4,79 @@ use test_log::test; use super::*; use crate::{AppConfig, assignment::KvAssignment}; +#[test] +fn subscription_flow_defaults_to_acp_without_changing_auth() { + let config = AppConfig::new_test().providers.llm.anthropic; + assert_eq!(config.subscription_flow, SubscriptionFlow::Acp); + assert_eq!(config.auth, vec![AuthEntry::ApiKey(None)]); +} + +#[test] +fn subscription_flow_assign_merge_and_delta() { + let mut before = PartialAnthropicConfig::default(); + before + .assign("subscription_flow=direct".parse().unwrap()) + .unwrap(); + assert_eq!(before.subscription_flow, Some(SubscriptionFlow::Direct)); + let mut after = before.clone(); + after + .assign("subscription_flow=acp".parse().unwrap()) + .unwrap(); + let delta = before.delta(after.clone()); + before.merge(&(), delta).unwrap(); + assert_eq!(before, after); + assert_eq!(before.delta(after).subscription_flow, None); +} + +#[test] +fn subscription_flow_defaults_fill_and_round_trip() { + let mut config = AppConfig::new_test().providers.llm.anthropic; + config.subscription_flow = SubscriptionFlow::Direct; + let partial = config.to_partial(); + assert_eq!(partial.subscription_flow, Some(SubscriptionFlow::Direct)); + let filled = PartialAnthropicConfig::default().fill_from(partial.clone()); + assert_eq!(filled.subscription_flow, Some(SubscriptionFlow::Direct)); + let mut explicit = PartialAnthropicConfig::default(); + explicit + .assign("subscription_flow=acp".parse().unwrap()) + .unwrap(); + assert_eq!( + explicit.fill_from(partial).subscription_flow, + Some(SubscriptionFlow::Acp) + ); +} + +#[test] +fn subscription_flow_null_clears_the_partial() { + let mut config = PartialAnthropicConfig::default(); + config + .assign("subscription_flow=direct".parse().unwrap()) + .unwrap(); + config + .assign("subscription_flow:=null".parse().unwrap()) + .unwrap(); + assert_eq!(config.subscription_flow, None); +} + +#[test] +fn subscription_flow_serde_and_validation() { + assert_eq!( + serde_json::to_string(&SubscriptionFlow::Direct).unwrap(), + r#""direct""# + ); + assert_eq!( + serde_json::from_str::(r#""acp""#).unwrap(), + SubscriptionFlow::Acp + ); + assert!(serde_json::from_str::(r#""automatic""#).is_err()); + let mut config = PartialAnthropicConfig::default(); + assert!( + config + .assign("subscription_flow=automatic".parse().unwrap()) + .is_err() + ); +} + #[test] fn test_auth_entry_from_str() { let cases = [ diff --git a/crates/jp_config/src/snapshots/jp_config__tests__app_config_fields.snap b/crates/jp_config/src/snapshots/jp_config__tests__app_config_fields.snap index c8d5bd147..1a735cfa9 100644 --- a/crates/jp_config/src/snapshots/jp_config__tests__app_config_fields.snap +++ b/crates/jp_config/src/snapshots/jp_config__tests__app_config_fields.snap @@ -84,6 +84,7 @@ expression: "AppConfig::fields()" "providers.llm.anthropic.base_url", "providers.llm.anthropic.beta_headers", "providers.llm.anthropic.chain_on_max_tokens", + "providers.llm.anthropic.subscription_flow", "plugins.auto_install", "plugins.command", "plugins.shutdown_timeout_secs", diff --git a/crates/jp_config/src/snapshots/jp_config__tests__app_config_schema_shape.snap b/crates/jp_config/src/snapshots/jp_config__tests__app_config_schema_shape.snap index 86f3848f2..72fd2871b 100644 --- a/crates/jp_config/src/snapshots/jp_config__tests__app_config_schema_shape.snap +++ b/crates/jp_config/src/snapshots/jp_config__tests__app_config_schema_shape.snap @@ -1348,6 +1348,7 @@ providers: ProviderConfig strategy?: "append" | "prepend" | "replace" | null value?: [string] chain_on_max_tokens?: bool + subscription_flow?: "acp" | "direct" cerebras: CerebrasConfig api_key_env?: ApiKeyEnv |: string diff --git a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default.snap b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default.snap index d657473f6..06e52f410 100644 --- a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default.snap +++ b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default.snap @@ -224,6 +224,7 @@ PartialAppConfig { ), anthropic: PartialAnthropicConfig { auth: None, + subscription_flow: None, api_key_env: None, base_url: None, chain_on_max_tokens: None, diff --git a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default_values.snap b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default_values.snap index 8b44de4c2..9771b33c8 100644 --- a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default_values.snap +++ b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default_values.snap @@ -460,6 +460,7 @@ Ok( ), ], ), + subscription_flow: None, api_key_env: Some( One( "ANTHROPIC_API_KEY", diff --git a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_empty_serialize.snap b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_empty_serialize.snap index 7ad9999ee..5288f6b38 100644 --- a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_empty_serialize.snap +++ b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_empty_serialize.snap @@ -224,6 +224,7 @@ PartialAppConfig { ), anthropic: PartialAnthropicConfig { auth: None, + subscription_flow: None, api_key_env: None, base_url: None, chain_on_max_tokens: None, diff --git a/crates/jp_llm/Cargo.toml b/crates/jp_llm/Cargo.toml index db7b92e87..ab3d25c43 100644 --- a/crates/jp_llm/Cargo.toml +++ b/crates/jp_llm/Cargo.toml @@ -17,10 +17,8 @@ jp_attachment = { workspace = true } jp_config = { workspace = true } jp_conversation = { workspace = true } jp_credentials = { workspace = true } -jp_mcp = { workspace = true } jp_openrouter = { workspace = true } jp_tool = { workspace = true } - async-anthropic = { workspace = true } async-stream = { workspace = true } async-trait = { workspace = true } @@ -31,13 +29,6 @@ futures = { workspace = true } gemini_client_rs = { workspace = true } getrandom = { workspace = true } indexmap = { workspace = true } -minijinja = { workspace = true, features = [ - "builtins", - "json", - "preserve_order", - "serde", - "unicode", -] } ollama-rs = { workspace = true, features = ["rustls", "stream"] } openai_responses = { workspace = true, features = ["stream"] } quick-xml = { workspace = true, features = ["serialize"] } @@ -49,13 +40,14 @@ serde_json = { workspace = true, features = ["preserve_order"] } sha2 = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } -tokio-util = { workspace = true } tracing = { workspace = true } url = { workspace = true } -uuid = { workspace = true, features = ["v5"] } +process-wrap = { workspace = true, features = ["tokio1", "process-group", "job-object", "kill-on-drop"] } +uuid = { workspace = true, features = ["v4", "v5", "serde"] } [dev-dependencies] assert_matches = { workspace = true } +camino-tempfile = { workspace = true } saphyr = { workspace = true } tokio = { workspace = true, features = ["test-util"] } datetime_literal = { workspace = true } @@ -67,6 +59,7 @@ jp_storage = { workspace = true } jp_test = { workspace = true } paste = { workspace = true } test-log = { workspace = true } +tracing-subscriber = { workspace = true } [lints] workspace = true diff --git a/crates/jp_llm/src/error.rs b/crates/jp_llm/src/error.rs index 9d1937233..11223225a 100644 --- a/crates/jp_llm/src/error.rs +++ b/crates/jp_llm/src/error.rs @@ -7,7 +7,8 @@ use async_anthropic::errors::AnthropicError; use chrono::{DateTime, Utc}; use jp_config::model::{id::ProviderId, parameters::ServiceTier}; use reqwest::header::{HeaderMap, RETRY_AFTER}; -use serde_json::Value; + +use crate::provider::anthropic::acp::Error as AnthropicAcpError; pub(crate) type Result = std::result::Result; @@ -39,6 +40,16 @@ pub struct StreamError { /// account-wide. pub quota_scope: Option, + /// How many of the exhausted limit's usage windows the provider reported as + /// spent. + /// + /// A subscription limit reports a short window and a long one. + /// A reset credit reopens one of them, so a request stays refused while + /// more than one is spent. + /// + /// `0` when the provider reported no window state at all. + pub quota_spent_windows: usize, + /// Human-readable error message. message: String, @@ -59,6 +70,7 @@ impl StreamError { retry_after: None, quota_reset: None, quota_scope: None, + quota_spent_windows: 0, source: None, } } @@ -467,6 +479,10 @@ pub enum StreamErrorKind { /// This is not retryable because a retry regenerates the same runaway. OutputLimit, + /// The provider could not finish within its output-token limit. + /// Distinct from the Host's output-byte ceiling. + MaxOutputTokens, + /// Other errors that are not categorized. /// These may or may not be retryable depending on the specific error. Other, @@ -486,6 +502,7 @@ impl StreamErrorKind { Self::AuthRejected => "Authentication rejected", Self::ContextWindowExceeded => "Context window exceeded", Self::OutputLimit => "Output limit exceeded", + Self::MaxOutputTokens => "Output token limit exceeded", Self::Other => "Stream Error", } } @@ -555,6 +572,10 @@ pub enum Error { #[error("Request error: {0}")] Request(#[from] reqwest::Error), + /// The Claude Code subscription flow failed its compatibility checks. + #[error(transparent)] + AnthropicAcp(#[from] AnthropicAcpError), + #[error("Anthropic error: {0}")] Anthropic(#[from] AnthropicError), @@ -607,82 +628,6 @@ impl PartialEq for Error { } } -#[derive(Debug, thiserror::Error)] -pub enum ToolError { - #[error("Tool not found: {name}")] - NotFound { name: String }, - - #[error("Tools not found: {}", names.join(", "))] - NotFoundN { names: Vec }, - - #[error("Disabled in configuration")] - Disabled, - - #[error("Command is only supported for local tools")] - UnexpectedCommand, - - #[error("Command missing for local tool")] - MissingCommand, - - #[error("Failed to fetch tool from MCP client")] - McpGetToolError(#[source] jp_mcp::Error), - - #[error("Failed to run tool from MCP client")] - McpRunToolError(#[source] jp_mcp::Error), - - #[error("Failed to serialize tool arguments")] - SerializeArgumentsError { - arguments: Value, - #[source] - error: serde_json::Error, - }, - - #[error("Tool call failed: {0}")] - ToolCallFailed(String), - - #[error("Failed to spawn command: {command}")] - SpawnError { - command: String, - #[source] - error: std::io::Error, - }, - - #[error("Failed to edit tool call")] - EditArgumentsError { - arguments: Value, - #[source] - error: serde_json::Error, - }, - - #[error("Template error")] - TemplateError { - data: String, - #[source] - error: minijinja::Error, - }, - - #[error("Invalid schema at `{path}`: {message}")] - InvalidSchema { path: String, message: String }, - - #[error("Needs input: {question:?}")] - NeedsInput { question: jp_tool::Question }, - - #[error("Skipped tool execution")] - Skipped { reason: Option }, - - #[error("Serialization error")] - Serde(#[from] serde_json::Error), - - #[error("Invalid arguments (missing: {missing:?}, unknown: {unknown:?})")] - Arguments { - /// Required arguments that were missing. - missing: Vec, - - /// Unknown arguments that were provided. - unknown: Vec, - }, -} - impl From for Error { fn from(error: jp_conversation::StreamError) -> Self { Self::Conversation(error.into()) @@ -728,18 +673,6 @@ impl From for Error { } } -#[cfg(test)] -impl PartialEq for ToolError { - fn eq(&self, other: &Self) -> bool { - if std::mem::discriminant(self) != std::mem::discriminant(other) { - return false; - } - - // Good enough for testing purposes - format!("{self:?}") == format!("{other:?}") - } -} - /// Heuristic check for quota/billing exhaustion based on error text. /// /// This catches the common patterns across providers: diff --git a/crates/jp_llm/src/event.rs b/crates/jp_llm/src/event.rs index 8bd84135b..17cf23b5d 100644 --- a/crates/jp_llm/src/event.rs +++ b/crates/jp_llm/src/event.rs @@ -75,6 +75,25 @@ pub enum Event { /// It signals only that the connection is still alive. KeepAlive, + /// A tool's identity is known, but its arguments or delegation are pending. + /// This is display-only progress: it is not buffered, persisted, or + /// executed. + /// A complete tool call is delivered separately through `Part` and `Flush`. + ToolCallPending { + /// Provider-issued tool call ID used to associate progress with the + /// final call. + id: String, + /// JP tool name, not its provider-specific alias. + name: String, + }, + + /// Remove pending-arguments progress that the provider has abandoned. + /// This neither cancels execution nor records a tool response. + ToolCallPendingEnd { + /// The identity used by the earlier pending notification. + id: String, + }, + /// A user-facing notice from the provider, rendered as chrome on stderr. /// /// Announces provider-level decisions the user must see — a skipped diff --git a/crates/jp_llm/src/event_builder.rs b/crates/jp_llm/src/event_builder.rs index aa0f23e01..3c79c1ec9 100644 --- a/crates/jp_llm/src/event_builder.rs +++ b/crates/jp_llm/src/event_builder.rs @@ -65,8 +65,12 @@ pub fn structured_data(events: Vec) -> Option { flushed.extend(builder.handle_flush(index, metadata)); } Event::Finished(_) => flushed.extend(builder.drain()), - // A notice is chrome, and carries no structured payload. - Event::Patch(_) | Event::KeepAlive | Event::Notice(_) => {} + // Control and progress events carry no structured payload. + Event::Patch(_) + | Event::KeepAlive + | Event::ToolCallPending { .. } + | Event::ToolCallPendingEnd { .. } + | Event::Notice(_) => {} } } diff --git a/crates/jp_llm/src/lib.rs b/crates/jp_llm/src/lib.rs index 56223cc73..514f31f18 100644 --- a/crates/jp_llm/src/lib.rs +++ b/crates/jp_llm/src/lib.rs @@ -8,7 +8,6 @@ pub mod query; pub mod retry; mod stream; pub mod title; -pub mod tool; pub mod window; #[cfg(test)] @@ -19,10 +18,9 @@ pub(crate) mod test; mod cross_route_tests; pub use credential::{AccountIdentity, Credential, ProviderAuth, provider_auth}; -pub use error::{Error, StreamError, StreamErrorKind, ToolError}; +pub use error::{Error, StreamError, StreamErrorKind}; pub use provider::Provider; pub use retry::{exponential_backoff, retry_delay}; pub use stream::{ EventStream, chain::EventChain, with_idle_timeout, with_output_limit, with_tool_call_keepalive, }; -pub use tool::{CommandResult, ExecutionOutcome, ToolTrace, run_tool_command}; diff --git a/crates/jp_llm/src/provider.rs b/crates/jp_llm/src/provider.rs index 71bb9136a..cb81743ca 100644 --- a/crates/jp_llm/src/provider.rs +++ b/crates/jp_llm/src/provider.rs @@ -26,15 +26,39 @@ use llamacpp::Llamacpp; use ollama::Ollama; use openai::Openai; use openrouter::Openrouter; +use serde_json::{Map, Value}; use vllm::Vllm; use crate::{ - error::Result, model::ModelDetails, provider::mock::MockProvider, query::ChatQuery, + error::Result, + model::ModelDetails, + provider::mock::MockProvider, + query::{ChatQuery, QueryContext, QueryStream, ToolExecution}, stream::EventStream, }; #[async_trait] pub trait Provider: Send + Sync { + /// Provider-specific hints advertised by the Host on its MCP tool + /// descriptions. + fn mcp_tool_metadata(&self, _model: &ModelDetails) -> Map { + Map::new() + } + /// Start a request with Host resources for provider-owned tool + /// continuation. + /// API providers use the ordinary response stream and caller-side dispatch. + async fn start_query( + &self, + model: &ModelDetails, + query: ChatQuery, + _context: QueryContext, + ) -> Result { + Ok(QueryStream { + events: self.chat_completion_stream(model, query).await?, + execution: ToolExecution::Caller, + }) + } + /// Get details of a model. async fn model_details(&self, name: &Name) -> Result; diff --git a/crates/jp_llm/src/provider/anthropic.rs b/crates/jp_llm/src/provider/anthropic.rs index 816d47d8d..481c7037f 100644 --- a/crates/jp_llm/src/provider/anthropic.rs +++ b/crates/jp_llm/src/provider/anthropic.rs @@ -1,16 +1,13 @@ +pub mod acp; pub mod auth; +mod http; pub mod oauth; pub mod resolve; -use std::{ - mem, - ops::RangeInclusive, - sync::{Arc, Mutex}, - time::Duration, -}; +use std::{env, mem, ops::RangeInclusive, time::Duration}; use async_anthropic::{ - Client, bearer, + Client, errors::AnthropicError, messages::DEFAULT_MAX_TOKENS, types::{ @@ -21,22 +18,26 @@ use async_anthropic::{ use async_stream::try_stream; use async_trait::async_trait; use base64::Engine as _; +use camino::Utf8PathBuf; use chrono::{NaiveDate, Utc}; use futures::{StreamExt as _, TryStreamExt as _, pin_mut, stream}; use jp_attachment::AttachmentContent; +#[cfg(test)] +use jp_config::providers::llm::anthropic::AuthEntry; use jp_config::{ assistant::{request::CachePolicy, tool_choice::ToolChoice}, model::{ id::{Name, ProviderId}, parameters::{ReasoningConfig, ReasoningEffort, ServiceTier}, }, - providers::llm::anthropic::{AnthropicConfig, AuthEntry}, + providers::llm::anthropic::{AnthropicConfig, SubscriptionFlow}, }; use jp_conversation::{ ConversationStream, event::{ChatResponse, ConversationEvent, EventKind}, }; use jp_credentials::CredentialStore; +use jp_tool::ToolDefinition; use serde_json::{Map, Value, json}; use tracing::{debug, info, trace, warn}; @@ -50,9 +51,8 @@ use crate::{ event::{Event, EventMatcher, EventPart, EventPatch, FinishReason, PatchAction, ToolCallPart}, event_builder::EventBuilder, model::{ModelDeprecation, ModelDetails, ReasoningDetails, ReasoningMode}, - query::ChatQuery, + query::{ChatQuery, QueryContext, QueryStream, ToolExecution}, stream::{EventStream, chain::find_merge_point, with_tool_call_keepalive}, - tool::ToolDefinition, }; static PROVIDER: ProviderId = ProviderId::Anthropic; @@ -168,10 +168,10 @@ pub struct Anthropic { /// Which beta features are enabled. beta: BetaFeatures, - /// The credential store backing `subscription` chain entries. + /// The store used for direct subscription tokens and named-credential + /// lookup. /// - /// `None` when the chain holds no profile entries; the default - /// `["api_key"]` chain works without touching the store. + /// API-key-only chains and unnamed ACP subscriptions do not open the store. store: Option, /// A directly injected credential, bypassing chain resolution. @@ -188,7 +188,7 @@ pub struct Anthropic { /// per request. /// A credential switch replaces the entry, since the auth material is baked /// into the client's default headers. - client_cache: Arc>>, + client_cache: http::Clients, /// Notices this provider has already surfaced. /// @@ -213,10 +213,7 @@ impl Anthropic { /// Returns an error when the chain, the store, or one of their entries is /// config-shaped-broken, or when no chain entry can resolve. pub fn new(config: &AnthropicConfig) -> Result { - let store = config - .auth - .iter() - .any(AuthEntry::may_need_store) + let store = resolve::needs_store(config) .then(CredentialStore::file_default) .transpose() .map_err(resolve::ResolveError::from)?; @@ -227,7 +224,7 @@ impl Anthropic { chain_on_max_tokens: config.chain_on_max_tokens, store, fixed_credential: None, - client_cache: Arc::new(Mutex::new(None)), + client_cache: http::Clients::default(), seen_notices: resolve::SeenNotices::default(), }; @@ -251,7 +248,7 @@ impl Anthropic { chain_on_max_tokens: config.chain_on_max_tokens, store: None, fixed_credential: Some(credential), - client_cache: Arc::new(Mutex::new(None)), + client_cache: http::Clients::default(), seen_notices: resolve::SeenNotices::default(), } } @@ -313,58 +310,11 @@ impl Anthropic { /// Bearer requests carry the Claude Code fingerprint, including the /// identity line leading the system content, so the flag feeds request /// construction, not only the auth header. - fn client_for(&self, credential: &Credential) -> Result<(Client, bool)> { - let mut cache = self.client_cache.lock().expect("poisoned"); - if let Some((cached, client, bearer)) = cache.as_ref() - && cached == credential - { - return Ok((client.clone(), *bearer)); - } - - let mut builder = Client::builder(); - builder - .base_url(self.config.base_url.clone()) - .version("2023-06-01"); - - let bearer = match credential { - Credential::ApiKey(key) => { - builder.api_key(key.clone()); - false - } - Credential::Bearer(token) => { - builder.auth_token(token.clone()); - true - } - }; - - if !self.config.beta_headers.is_empty() { - builder.beta(self.config.beta_headers.join(",")); + fn client_for(&self, route: &resolve::Route) -> Result<(Client, bool)> { + match route { + resolve::Route::Http(credential) => self.client_cache.get(&self.config, credential), + resolve::Route::Acp => Err(acp::Error::FlowChanged.into()), } - - // Bearer mode changes the request fingerprint, not just the auth - // header, so record which mode a request went out in and the beta - // set that accompanied it. - debug!( - bearer, - betas = %if bearer { - bearer::merge_betas( - (!self.config.beta_headers.is_empty()) - .then(|| self.config.beta_headers.join(",")) - .as_deref(), - ) - } else { - self.config.beta_headers.join(",") - }, - "Constructing Anthropic client." - ); - - let client = builder - .build() - .map_err(|e| Error::Anthropic(AnthropicError::Unknown(e.to_string())))?; - - *cache = Some((credential.clone(), client.clone(), bearer)); - - Ok((client, bearer)) } } @@ -380,6 +330,34 @@ fn is_switchable(error: &AnthropicError) -> bool { #[async_trait] impl Provider for Anthropic { + fn mcp_tool_metadata(&self, model: &ModelDetails) -> Map { + if model.served_by_subscription() && self.config.subscription_flow == SubscriptionFlow::Acp + { + return Map::from_iter([("anthropic/maxResultSizeChars".into(), 500_000.into())]); + } + Map::new() + } + async fn start_query( + &self, + model: &ModelDetails, + query: ChatQuery, + context: QueryContext, + ) -> Result { + let attempt = self.resolve(model.name()).await?; + if matches!(attempt.route, resolve::Route::Acp) { + acp::inspect().await?; + return Ok(QueryStream { + events: acp::stream(model, query, context)?, + execution: ToolExecution::Agent { + correlation_key: "claudecode/toolUseId", + }, + }); + } + Ok(QueryStream { + events: self.chat_completion_stream(model, query).await?, + execution: ToolExecution::Caller, + }) + } async fn model_details(&self, name: &Name) -> Result { let mut attempt = self.resolve(name).await?; @@ -388,7 +366,12 @@ impl Provider for Anthropic { warn!("{notice}"); } - let (client, _) = self.client_for(&attempt.credential)?; + if matches!(attempt.route, resolve::Route::Acp) { + let model = acp::model_details(name); + acp::inspect().await?; + return Ok(model); + } + let (client, _) = self.client_for(&attempt.route)?; match client.models().get(name).await { Ok(model) => return map_model(model), Err(error) => attempt = self.advance_or_fail(&attempt, error, name).await?, @@ -406,7 +389,11 @@ impl Provider for Anthropic { warn!("{notice}"); } - let (client, _) = self.client_for(&attempt.credential)?; + if matches!(attempt.route, resolve::Route::Acp) { + acp::inspect().await?; + return Ok(vec![acp::model_details(&"claude-opus-5".parse()?)]); + } + let (client, _) = self.client_for(&attempt.route)?; let mut all_models = vec![]; let mut after_id = None; @@ -454,6 +441,16 @@ impl Provider for Anthropic { // ordinary error before a stream exists; switches re-resolve inside // the stream. let attempt = self.resolve(model.name()).await?; + if matches!(attempt.route, resolve::Route::Acp) { + acp::inspect().await?; + let root = env::current_dir().map_err(acp::Error::NativeIo)?; + let root = Utf8PathBuf::from_path_buf(root).map_err(|_| acp::Error::NativeDirectory)?; + return acp::stream(model, query, QueryContext { + root, + mcp_endpoint: None, + invocation: None, + }); + } let this = self.clone(); let model = model.clone(); @@ -469,7 +466,7 @@ impl Provider for Anthropic { // API key request differ in fingerprint, not only in the auth // header. let (client, bearer) = this - .client_for(&attempt.credential) + .client_for(&attempt.route) .map_err(|e| StreamError::other(e.to_string()))?; let (request, is_structured, forced_tool) = create_request(&model, query.clone(), true, &this.beta, bearer) @@ -514,7 +511,13 @@ impl Provider for Anthropic { match item { Ok(event) => { content_seen = content_seen - || !matches!(event, Event::KeepAlive | Event::Notice(_)); + || !matches!( + event, + Event::KeepAlive + | Event::ToolCallPending { .. } + | Event::ToolCallPendingEnd { .. } + | Event::Notice(_) + ); yield event; } Err(error) if error.needs_credential_switch() => { @@ -839,7 +842,9 @@ fn call( yield flush; } patch @ Event::Patch(_) => yield patch, - keep_alive @ Event::KeepAlive => yield keep_alive, + progress @ (Event::KeepAlive + | Event::ToolCallPending { .. } + | Event::ToolCallPendingEnd { .. }) => yield progress, notice @ Event::Notice(_) => yield notice, } } @@ -1873,7 +1878,7 @@ fn create_request( warn!( %model.id, %DEFAULT_MAX_TOKENS, - "Model `max_tokens` parameter not found, using default value." + "HTTP request serialization has no configured or reported output limit; using its fallback." ); DEFAULT_MAX_TOKENS as u32 @@ -2408,8 +2413,7 @@ fn map_model(model: types::Model) -> Result { // Only a model in the table has a known answer; the API reports nothing // about prefill. prefill: known.then_some(overrides.prefill), - // Anthropic bills its subscription through Claude Code rather than - // through JP, so no model here is reachable with one. + // The HTTP model catalog does not report subscription availability. subscription: None, features, }) @@ -2423,7 +2427,7 @@ fn map_event( trace!( event = serde_json::to_string(&event).unwrap_or_default(), - "Received event from Anthropic API." + "Received Anthropic message stream event." ); match event { @@ -3209,7 +3213,7 @@ fn erase_model_output(block: &mut Value) { #[cfg(test)] static SUBSCRIPTION_ROUTE: SubscriptionTestRoute = SubscriptionTestRoute; -/// The Claude subscription route. +/// The direct HTTP subscription route. /// /// Recording authenticates exactly as a user's own machine does: through the /// credential store, resolved by the same chain a real request walks. @@ -3251,6 +3255,7 @@ impl super::ProviderTestRoute for SubscriptionTestRoute { let mut config = config.anthropic.clone(); config.auth = vec![AuthEntry::Subscription(Some(profile))]; + config.subscription_flow = SubscriptionFlow::Direct; Anthropic::new(&config) .map(|provider| Box::new(provider) as Box) diff --git a/crates/jp_llm/src/provider/anthropic/acp.rs b/crates/jp_llm/src/provider/anthropic/acp.rs new file mode 100644 index 000000000..1a1ec04f8 --- /dev/null +++ b/crates/jp_llm/src/provider/anthropic/acp.rs @@ -0,0 +1,342 @@ +//! Compatibility checks for the Claude Code subscription flow. +//! +//! Inspection invokes version and authentication commands only. +//! No prompt is submitted and JP never reads Claude Code's credential files. + +use std::{ + fmt, io, + process::{ExitStatus, Stdio}, + str::{self, Utf8Error}, + time::Duration, +}; + +use jp_config::model::id::{ModelIdConfig, Name, ProviderId}; +use serde::Deserialize; +use tokio::{io::AsyncReadExt as _, process::Command, time::timeout}; +use tracing::warn; + +use crate::{error::StreamError, model::ModelDetails}; + +mod cassette; +mod options; +mod process; +mod protocol; +mod rpc; +mod schema; +mod transcript; +mod transport; +mod usage; + +use rpc::RpcError; +use schema::SessionConfigId; +pub(super) use transport::stream; + +/// A failure in the Claude Code subscription flow. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The adapter did not confirm a requested session setting. + #[error("Claude adapter did not apply session setting `{setting}`")] + SettingNotApplied { setting: SessionConfigId }, + + /// Initialization did not establish the required protocol and transports. + #[error("Claude adapter lacks the required ACP v1/HTTP MCP capabilities")] + InitializationCapabilities, + /// Native history cannot be supplied to this adapter. + #[error("Claude adapter does not support history loading")] + HistoryLoadingUnsupported, + /// A successful ACP response did not include the SDK's final outcome. + #[error("Claude adapter ended without an SDK result")] + MissingSdkResult, + /// Tool-using agent requests need JP's execution endpoint. + #[error("ACP tool execution requires the JP MCP Host")] + ToolHostRequired, + /// The native transcript directory cannot be determined safely. + #[error( + "Claude native history requires an absolute configuration directory and working directory" + )] + NativeDirectory, + /// Derived transcript storage failed. + #[error("Claude native transcript I/O failed")] + NativeIo(#[source] io::Error), + /// Derived transcript serialization failed. + #[error("Claude native transcript serialization failed")] + NativeJson(#[source] serde_json::Error), + /// The ACP peer rejected a request or the protocol connection failed. + #[error("Claude ACP protocol error: {0}")] + Protocol(#[source] RpcError), + /// JP names do not select Claude Code accounts. + #[error( + "subscription credential `{name}` is not mapped to a Claude Code login; use an unnamed \ + `subscription` entry, or explicitly set providers.llm.anthropic.subscription_flow=direct \ + to use JP-stored credentials (account-policy risk)" + )] + NamedSubscription { name: String }, + /// The adapter could not be started or its output could not be read. + #[error( + "Claude ACP {check} failed; install @agentclientprotocol/claude-agent-acp@0.76.0 with \ + Node.js 22+ and optional dependencies enabled" + )] + Io { + check: Check, + #[source] + source: io::Error, + }, + /// A prerequisite command did not complete within its deadline. + #[error("Claude ACP {check} timed out")] + Timeout { check: Check }, + /// A prerequisite command failed. + #[error( + "Claude ACP {check} exited with {status}; check `claude-agent-acp --cli auth status \ + --json`" + )] + CommandFailed { check: Check, status: ExitStatus }, + /// A command exceeded the bounded diagnostic output size. + #[error("Claude ACP {check} returned more than 65536 bytes")] + OutputLimit { check: Check }, + /// A version command returned invalid UTF-8. + #[error("Claude ACP {check} returned invalid UTF-8")] + Encoding { + check: Check, + #[source] + source: Utf8Error, + }, + /// The installed versions have not been qualified together. + #[error("unsupported Claude ACP {check}: {actual:?}; expected {expected}")] + UnsupportedVersion { + check: Check, + actual: String, + expected: &'static str, + }, + /// Authentication output is not the expected JSON representation. + #[error("Claude Code returned invalid authentication status")] + AuthStatus(#[source] serde_json::Error), + /// Effective subscription authentication could not be established. + #[error( + "Claude Code must use an active Pro or Max subscription login; run `claude-agent-acp \ + --cli auth login --claudeai` and remove conflicting API-key/helper or cloud \ + configuration; disable paid Usage credits to prevent overage" + )] + SubscriptionRequired, + /// Claude Code could not select the requested model. + #[error("Claude Code could not select model `{model}`: {source}")] + ModelSelection { + model: Name, + #[source] + source: RpcError, + }, + /// The runtime reports an unavailable model. + #[error("Claude Code cannot use model `{model}`: {detail}")] + ModelUnavailable { model: Name, detail: String }, + /// The runtime rejects the request or its model parameters. + #[error("Claude Code rejected the request for model `{model}`: {detail}")] + RequestRejected { model: Name, detail: String }, + /// A classified failure received through an ACP notification. + #[error(transparent)] + Stream(Box), + /// Changing request implementation requires a fresh Host context. + #[error( + "subscription flow changed to ACP during an HTTP request; retry using the current \ + conversation" + )] + FlowChanged, +} + +/// A non-inference operation used to inspect the installed runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Check { + /// Read the ACP adapter version. + AdapterVersion, + /// Read the bundled Claude Code version. + ClaudeVersion, + /// Read effective authentication without extracting credentials. + Authentication, +} + +impl fmt::Display for Check { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::AdapterVersion => "adapter-version check", + Self::ClaudeVersion => "Claude Code-version check", + Self::Authentication => "authentication check", + }) + } +} + +impl Check { + fn args(self) -> &'static [&'static str] { + match self { + Self::AdapterVersion => &["--version"], + Self::ClaudeVersion => &["--cli", "--version"], + Self::Authentication => &["--cli", "auth", "status", "--json"], + } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AuthStatus { + logged_in: bool, + auth_method: Option, + api_provider: Option, + subscription_type: Option, + api_key_source: Option, +} + +#[derive(Deserialize)] +enum AuthMethod { + #[serde(rename = "claude.ai")] + ClaudeAccount, + #[serde(other)] + Other, +} + +#[derive(Deserialize)] +enum ApiProvider { + #[serde(rename = "firstParty")] + FirstParty, + #[serde(other)] + Other, +} + +#[derive(Deserialize)] +enum Plan { + #[serde(rename = "pro", alias = "Claude Pro")] + Pro, + #[serde(rename = "max", alias = "Claude Max")] + Max, + #[serde(other)] + Other, +} + +/// Verify the installed adapter/runtime pair and its active subscription login. +pub(super) async fn inspect() -> Result<(), Error> { + let adapter = run(Check::AdapterVersion).await?; + let claude = run(Check::ClaudeVersion).await?; + qualify_versions(&adapter, &claude)?; + validate_auth(&run(Check::Authentication).await?) +} + +fn qualify_versions(adapter: &[u8], claude: &[u8]) -> Result<(), Error> { + for (check, output, expected) in [ + (Check::AdapterVersion, adapter, "0.76.0"), + (Check::ClaudeVersion, claude, "2.1.257"), + ] { + let actual = str::from_utf8(output) + .map_err(|source| Error::Encoding { check, source })? + .trim(); + let version = if check == Check::ClaudeVersion { + actual.strip_suffix(" (Claude Code)").unwrap_or(actual) + } else { + actual + }; + if version != expected { + return Err(Error::UnsupportedVersion { + check, + actual: actual.to_owned(), + expected, + }); + } + } + Ok(()) +} + +fn validate_auth(output: &[u8]) -> Result<(), Error> { + let status: AuthStatus = serde_json::from_slice(output).map_err(Error::AuthStatus)?; + if status.logged_in + && status.api_key_source.is_none() + && matches!(status.auth_method, Some(AuthMethod::ClaudeAccount)) + && matches!(status.api_provider, Some(ApiProvider::FirstParty)) + && matches!(status.subscription_type, Some(Plan::Pro | Plan::Max)) + { + return Ok(()); + } + Err(Error::SubscriptionRequired) +} + +/// Describe the selected model without an API-key-authenticated lookup. +/// Availability is determined by Claude Code when it receives the request. +pub(super) fn model_details(name: &Name) -> ModelDetails { + let mut model = ModelDetails::empty(ModelIdConfig { + provider: ProviderId::Anthropic, + name: name.clone(), + }); + model.subscription = Some(true); + model.prefill = Some(false); + model.structured_output = Some(true); + model +} + +fn removes_variable(name: &str) -> bool { + let normalized = name.to_ascii_uppercase(); + let name = normalized.as_str(); + name.starts_with("ANTHROPIC_") + || name.starts_with("CLAUDE_CODE_USE_") + || name.starts_with("DISABLE_PROMPT_CACHING") + || name == "CLAUDE_CODE_PROMPT_CACHE_TTL" + || matches!( + name, + "CLAUDE_CODE_OAUTH_TOKEN" + | "CLAUDE_CODE_API_KEY" + | "CLAUDECODE" + | "FORCE_PROMPT_CACHING_5M" + | "ENABLE_PROMPT_CACHING_1H" + ) +} + +async fn run(check: Check) -> Result, Error> { + let mut command = process::command(); + command.args(check.args()); + read_output(command, check).await +} + +async fn read_output(mut command: Command, check: Check) -> Result, Error> { + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + let mut child = process::spawn(command).map_err(|source| Error::Io { check, source })?; + let result = timeout(Duration::from_secs(15), async { + let mut bytes = Vec::new(); + child + .stdout() + .take() + .expect("stdout is piped") + .take(65_537) + .read_to_end(&mut bytes) + .await + .map_err(|source| Error::Io { check, source })?; + if bytes.len() > 65_536 { + return Err(Error::OutputLimit { check }); + } + let status = child + .wait() + .await + .map_err(|source| Error::Io { check, source })?; + if !status.success() { + return Err(Error::CommandFailed { check, status }); + } + Ok(bytes) + }) + .await; + let result = result.unwrap_or(Err(Error::Timeout { check })); + if result.is_err() + && child.id().is_some() + && let Err(error) = Box::into_pin(child.kill()).await + { + warn!(%error, %check, "Failed to stop Claude ACP inspection process."); + } + result +} + +#[cfg(test)] +#[path = "acp/recorded_tests.rs"] +mod recorded_tests; + +#[cfg(test)] +#[path = "acp/workflow_tests.rs"] +mod workflow_tests; + +#[cfg(test)] +#[path = "acp_tests.rs"] +mod tests; diff --git a/crates/jp_llm/src/provider/anthropic/acp/cassette.rs b/crates/jp_llm/src/provider/anthropic/acp/cassette.rs new file mode 100644 index 000000000..7ab8a2b38 --- /dev/null +++ b/crates/jp_llm/src/provider/anthropic/acp/cassette.rs @@ -0,0 +1,415 @@ +//! Recording and replay of one ACP conversation. +//! +//! A cassette is the stdio counterpart of `jp_test::mock::Vcr`, which records +//! HTTP by proxying it. +//! ACP speaks newline-delimited JSON-RPC over a child process's pipes, so there +//! is nothing for an HTTP proxy to intercept; what carries across is the +//! convention. +//! `RECORD` is the same switch, fixtures live under the same `tests/fixtures` +//! root, and playing back without a recording reports the path that is missing. +//! +//! A recording taken against the installed adapter is the only non-circular +//! evidence that [`super::schema`] spells the protocol's field names correctly. +//! Those types are written by hand, so a fixture written from the same reading +//! of the specification would agree with them whether or not the adapter does. +//! +//! One query can open several connections, and a recording holds all of them, +//! the way an HTTP cassette holds every exchange a test made. +//! Each line is one framed message, numbered by the connection it belongs to: +//! `{"connection": 0, "from": "jp"|"agent", "message": {...}}`. + +use std::{ + collections::HashMap, + env, fs, + io::Write as _, + path::{Path, PathBuf}, + sync::{ + Arc, LazyLock, Mutex, PoisonError, + atomic::{AtomicUsize, Ordering}, + }, +}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tracing::{info, warn}; + +use super::rpc::{Side, Tap}; + +/// One framed message, tagged with the connection and the end that sent it. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(super) struct Framed { + /// Which connection carried it, counting from zero within one process. + #[serde(default)] + pub(super) connection: usize, + + /// Which end sent it. + pub(super) from: Side, + + /// The JSON-RPC message itself, exactly as it crossed the pipe. + pub(super) message: Value, +} + +/// Where one recorded conversation lives. +/// +/// Prefers the package's `tests/fixtures` directory, which is where the rest of +/// the workspace keeps its cassettes. +/// `CARGO_MANIFEST_DIR` is unset when a release binary records a real query, +/// and the working directory is the only location that binary can be said to +/// have. +fn fixture(name: &str) -> PathBuf { + let root = env::var_os("CARGO_MANIFEST_DIR").map_or_else( + || PathBuf::from("."), + |root| PathBuf::from(root).join("tests/fixtures"), + ); + + root.join("acp").join(format!("{name}.jsonl")) +} + +/// Whether this run records rather than replays. +/// +/// Reads `RECORD`, the switch the workspace's HTTP cassettes already use, so +/// one habit covers both. +pub(super) fn recording() -> bool { + env::var("RECORD").is_ok() +} + +/// The recording each named fixture is accumulating in this process. +/// +/// A query opens one connection per request, and all of them belong in one +/// file; truncating per connection would leave only the last. +static RECORDINGS: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// One fixture being written, shared by every connection that lands in it. +struct Recording { + file: Mutex, + connections: AtomicUsize, +} + +/// What a redacted value is replaced with. +const REDACTED: &str = "[redacted]"; + +/// Values a recording must not carry into the repository. +/// +/// Each identifies the account that recorded it or the machine it ran on, and +/// none is read back: a replay answers by method rather than by arguments, and +/// JP supplies its own working directory either way. +/// +/// Named fields rather than a shape, so a field the adapter adds later is +/// recorded as-is. +/// A recording only changes when somebody deliberately re-records it, and that +/// diff is read before it merges. +const SENSITIVE: &[&[&str]] = &[ + &["params", "authStatus", "account", "email"], + &["params", "authStatus", "account", "organization"], + &["params", "cwd"], + &["params", "message", "cwd"], +]; + +/// Replace the value at `path`, if the message has one there. +fn redact(message: &mut Value, path: &[&str]) { + let Some((leaf, parents)) = path.split_last() else { + return; + }; + + let mut node = message; + for key in parents { + match node.get_mut(*key) { + Some(next) => node = next, + None => return, + } + } + + if let Some(value) = node.get_mut(*leaf) { + *value = Value::String(REDACTED.to_owned()); + } +} + +impl Recording { + fn write(&self, connection: usize, from: Side, message: &Value) { + let mut message = message.clone(); + for path in SENSITIVE { + redact(&mut message, path); + } + + let line = serde_json::to_string(&Framed { + connection, + from, + message, + }) + .expect("a framed message is JSON"); + let mut file = self.file.lock().unwrap_or_else(PoisonError::into_inner); + if let Err(error) = writeln!(file, "{line}") { + warn!(%error, "Could not append to the ACP recording"); + } + } +} + +/// Observe a connection into `.jsonl`, or observe nothing. +/// +/// Returns an inert tap unless `RECORD` is set, so the production call site +/// carries no branch of its own. +/// An unwritable path disables recording rather than failing the query: a +/// qualification run that reaches the adapter and loses its transcript is still +/// a qualification run. +pub(super) fn tap(name: &str) -> Tap { + if !recording() { + return Tap::none(); + } + + recorder(&fixture(name)) +} + +/// Observe one connection into `path`, numbering it after its predecessors. +/// +/// The file is truncated when this process first records into it, and appended +/// to by every connection after that. +fn recorder(path: &Path) -> Tap { + let mut recordings = RECORDINGS.lock().unwrap_or_else(PoisonError::into_inner); + let recording = match recordings.get(path) { + Some(recording) => recording.clone(), + None => match create(path) { + Ok(file) => { + info!(path = %path.display(), "Recording the ACP conversation"); + let recording = Arc::new(Recording { + file: Mutex::new(file), + connections: AtomicUsize::new(0), + }); + recordings.insert(path.to_owned(), recording.clone()); + recording + } + Err(error) => { + warn!(%error, path = %path.display(), "Could not open the ACP recording"); + return Tap::none(); + } + }, + }; + drop(recordings); + + let connection = recording.connections.fetch_add(1, Ordering::Relaxed); + Tap::new(move |from, message| recording.write(connection, from, message)) +} + +/// Truncate `path` and open it for writing, creating its directory. +fn create(path: &Path) -> std::io::Result { + if let Some(directory) = path.parent() { + fs::create_dir_all(directory)?; + } + + fs::File::create(path) +} + +/// Read `.jsonl`, or explain which recording is missing. +/// +/// # Errors +/// +/// Returns an error when the file is absent, unreadable, or holds a line that +/// is not a framed message. +#[cfg(test)] +pub(super) fn read(name: &str) -> Result, String> { + let path = fixture(name); + let contents = fs::read_to_string(&path) + .map_err(|error| format!("Recording not found at {}: {error}", path.display()))?; + + parse(&contents).map_err(|error| format!("{}: {error}", path.display())) +} + +/// Split a recording into one script per connection, in connection order. +/// +/// A query opens a connection per request, so a recording of one query holds +/// several. +/// Each script keeps its messages in the order they were recorded. +#[cfg(test)] +pub(super) fn connections(script: Vec) -> Vec> { + let mut grouped: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for entry in script { + grouped.entry(entry.connection).or_default().push(entry); + } + + grouped.into_values().collect() +} + +/// Parse a recording, one framed message per non-empty line. +/// +/// # Errors +/// +/// Returns the offending line number when a line is not a framed message. +#[cfg(test)] +fn parse(contents: &str) -> Result, String> { + contents + .lines() + .enumerate() + .filter(|(_, line)| !line.trim().is_empty()) + .map(|(index, line)| { + serde_json::from_str(line) + .map_err(|error| format!("line {}: not a framed message: {error}", index + 1)) + }) + .collect() +} + +/// Answers JP from a recording, over an in-memory pipe. +/// +/// Consumes one recorded JP message for each message JP sends, checking that +/// their methods agree, then writes back every agent message the recording +/// places before JP's next one. +/// An answer to one of JP's requests carries the live request's id rather than +/// the recorded one, since a replayed run allocates its own. +/// +/// A recording that stops agreeing with what JP sends ends the connection and +/// reports where, rather than letting JP wait for an answer that is never +/// coming. +#[cfg(test)] +pub(super) struct Recorded(pub(super) Vec); + +#[cfg(test)] +impl super::transport::Transport for Recorded { + fn connect( + self: Box, + handler: super::rpc::Handler, + foreground: super::transport::Foreground, + ) -> futures::future::BoxFuture<'static, Result<(), super::rpc::RpcError>> { + use futures::FutureExt as _; + + let (jp_writes, agent_reads) = tokio::io::duplex(1 << 16); + let (agent_writes, jp_reads) = tokio::io::duplex(1 << 16); + let divergence: Arc>> = Arc::default(); + let reported = divergence.clone(); + + tokio::spawn(async move { + if let Err(reason) = serve(self.0, agent_reads, agent_writes).await { + *divergence.lock().unwrap_or_else(PoisonError::into_inner) = Some(reason); + } + }); + + super::rpc::drive(jp_writes, jp_reads, Tap::none(), handler, foreground) + .map(move |result| { + // The divergence is the cause; whatever JP reported is the + // symptom of a pipe that closed underneath it. + match reported + .lock() + .unwrap_or_else(PoisonError::into_inner) + .take() + { + Some(reason) => Err(super::rpc::RpcError::into_internal_error(reason)), + None => result, + } + }) + .boxed() + } +} + +/// Answer JP from the recording until it stops sending or the script runs out. +/// +/// # Errors +/// +/// Returns the point at which the recording stopped describing what JP sent. +#[cfg(test)] +async fn serve(script: Vec, reads: R, mut writes: W) -> Result<(), String> +where + R: tokio::io::AsyncRead + Unpin, + W: tokio::io::AsyncWrite + Unpin, +{ + use tokio::io::AsyncBufReadExt as _; + + let mut lines = tokio::io::BufReader::new(reads).lines(); + let mut cursor = flush(&script, 0, &HashMap::new(), &mut writes).await?; + let mut ids: HashMap = HashMap::new(); + + while let Ok(Some(line)) = lines.next_line().await { + let live: Value = serde_json::from_str(&line) + .map_err(|error| format!("JP sent a line that is not JSON: {error}"))?; + + let Some(recorded) = script.get(cursor) else { + return Err(format!( + "JP sent {} after the recording ended", + describe(&live) + )); + }; + if recorded.from != Side::Jp { + return Err(format!( + "the recording expects the agent to speak next, but JP sent {}", + describe(&live) + )); + } + if method(&recorded.message) != method(&live) { + return Err(format!( + "the recording has {} where JP sent {}", + describe(&recorded.message), + describe(&live) + )); + } + if let (Some(recorded), Some(live)) = ( + recorded.message.get("id").and_then(Value::as_i64), + live.get("id").cloned(), + ) { + ids.insert(recorded, live); + } + + cursor = flush(&script, cursor + 1, &ids, &mut writes).await?; + } + + Ok(()) +} + +/// Write every agent message from `cursor` up to JP's next one. +#[cfg(test)] +async fn flush( + script: &[Framed], + mut cursor: usize, + ids: &HashMap, + writes: &mut W, +) -> Result { + use tokio::io::AsyncWriteExt as _; + + while let Some(entry) = script.get(cursor).filter(|entry| entry.from == Side::Agent) { + let mut message = entry.message.clone(); + // An answer to one of JP's requests: no method, and an id JP chose. + if method(&message).is_none() + && let Some(recorded) = message.get("id").and_then(Value::as_i64) + && let Some(live) = ids.get(&recorded) + { + message["id"] = live.clone(); + } + + let mut line = serde_json::to_vec(&message).expect("a recorded message is JSON"); + line.push(b'\n'); + writes + .write_all(&line) + .await + .map_err(|error| format!("could not replay {}: {error}", describe(&message)))?; + // The caller is waiting on this message and will send nothing more + // until it arrives, so a buffered writer has to be emptied rather than + // left to fill. Neither writer used today buffers. + writes + .flush() + .await + .map_err(|error| format!("could not replay {}: {error}", describe(&message)))?; + cursor += 1; + } + + Ok(cursor) +} + +/// The JSON-RPC method, when a message has one. +#[cfg(test)] +fn method(message: &Value) -> Option<&str> { + message.get("method").and_then(Value::as_str) +} + +/// Name a message the way a divergence report should: by method, or by the +/// request it answers. +#[cfg(test)] +fn describe(message: &Value) -> String { + match method(message) { + Some(method) => format!("`{method}`"), + None => match message.get("id") { + Some(id) => format!("an answer to request {id}"), + None => "a message with neither method nor id".to_owned(), + }, + } +} + +#[cfg(test)] +#[path = "cassette_tests.rs"] +mod tests; diff --git a/crates/jp_llm/src/provider/anthropic/acp/cassette_tests.rs b/crates/jp_llm/src/provider/anthropic/acp/cassette_tests.rs new file mode 100644 index 000000000..6412c2d2a --- /dev/null +++ b/crates/jp_llm/src/provider/anthropic/acp/cassette_tests.rs @@ -0,0 +1,474 @@ +//! Tests for the cassette machinery itself. +//! +//! Every script here is written by hand, so none of it is evidence about the +//! adapter's wire format. +//! What it covers is the replay harness: that a recorded conversation reaches +//! JP in the right order, that an answer finds the request it belongs to, and +//! that a script which stops agreeing with JP says so. + +use std::time::Duration; + +use serde_json::json; +use tokio::time::timeout; + +use super::*; +use crate::provider::anthropic::acp::{ + rpc::{Handler, Inbound, Request, RpcError, drive}, + transport::{Foreground, Transport as _}, +}; + +/// A request with no parameters, standing in for whatever JP sends first. +#[derive(Serialize)] +struct Ping; + +impl Request for Ping { + const METHOD: &'static str = "ping"; + + type Response = Value; +} + +/// A second method, for proving a divergence is reported against the right one. +#[derive(Serialize)] +struct Pong; + +impl Request for Pong { + const METHOD: &'static str = "pong"; + + type Response = Value; +} + +fn jp(message: Value) -> Framed { + Framed { + connection: 0, + from: Side::Jp, + message, + } +} + +fn agent(message: Value) -> Framed { + Framed { + connection: 0, + from: Side::Agent, + message, + } +} + +/// A handler that answers every agent request with `answer` and accepts every +/// notification, recording what it saw. +fn handler(answer: Value, seen: Arc>>) -> Handler { + Box::new(move |inbound| { + let answer = answer.clone(); + let seen = seen.clone(); + Box::pin(async move { + let method = match inbound { + Inbound::Notification { method, .. } | Inbound::Request { method, .. } => method, + }; + seen.lock() + .unwrap_or_else(PoisonError::into_inner) + .push(method); + Ok(answer) + }) + }) +} + +/// A foreground that sends one `Ping` and keeps the answer. +fn ping_into(slot: Arc>>) -> Foreground { + Box::new(move |peer| { + Box::pin(async move { + let answer = peer.request(Ping).await?; + *slot.lock().unwrap_or_else(PoisonError::into_inner) = Some(answer); + Ok(()) + }) + }) +} + +/// Run `script` against a foreground sequence, returning its outcome. +async fn against( + script: Vec, + foreground: Foreground, +) -> (Result<(), RpcError>, Vec) { + let seen = Arc::new(Mutex::new(Vec::new())); + let transport = Box::new(Recorded(script)); + let outcome = timeout( + Duration::from_secs(5), + transport.connect(handler(Value::Null, seen.clone()), foreground), + ) + .await + .expect("the replayed connection did not settle"); + let seen = seen.lock().unwrap_or_else(PoisonError::into_inner).clone(); + (outcome, seen) +} + +#[tokio::test] +async fn a_recorded_answer_reaches_the_request_it_belongs_to() { + let script = vec![ + jp(json!({"jsonrpc": "2.0", "id": 1, "method": "ping", "params": null})), + agent(json!({"jsonrpc": "2.0", "id": 1, "result": {"pong": true}})), + ]; + let answered = Arc::new(Mutex::new(None)); + let captured = answered.clone(); + let foreground: Foreground = Box::new(move |peer| { + Box::pin(async move { + let response = peer.request(Ping).await?; + *captured.lock().unwrap_or_else(PoisonError::into_inner) = Some(response); + Ok(()) + }) + }); + + let (outcome, _) = against(script, foreground).await; + + outcome.unwrap(); + assert_eq!( + answered + .lock() + .unwrap_or_else(PoisonError::into_inner) + .take(), + Some(json!({"pong": true})) + ); +} + +/// A recording made in one run carries that run's ids, and a replay allocates +/// its own from one. +/// An answer keyed on the recorded id would never be delivered. +#[tokio::test] +async fn an_answer_recorded_under_another_id_is_still_delivered() { + let script = vec![ + jp(json!({"jsonrpc": "2.0", "id": 74, "method": "ping", "params": null})), + agent(json!({"jsonrpc": "2.0", "id": 74, "result": {"pong": true}})), + ]; + let answered = Arc::new(Mutex::new(None)); + let captured = answered.clone(); + let foreground: Foreground = Box::new(move |peer| { + Box::pin(async move { + let response = peer.request(Ping).await?; + *captured.lock().unwrap_or_else(PoisonError::into_inner) = Some(response); + Ok(()) + }) + }); + + let (outcome, _) = against(script, foreground).await; + + outcome.unwrap(); + assert_eq!( + answered + .lock() + .unwrap_or_else(PoisonError::into_inner) + .take(), + Some(json!({"pong": true})) + ); +} + +/// The shape a permission prompt takes: the agent interrupts with a request of +/// its own while JP's is still open, and JP's answer comes before the result. +#[tokio::test] +async fn an_agent_request_is_served_while_jps_own_is_outstanding() { + let script = vec![ + jp(json!({"jsonrpc": "2.0", "id": 1, "method": "ping", "params": null})), + agent(json!({"jsonrpc": "2.0", "method": "notice", "params": {}})), + agent(json!({"jsonrpc": "2.0", "id": 900, "method": "permission", "params": {}})), + jp(json!({"jsonrpc": "2.0", "id": 900, "result": null})), + agent(json!({"jsonrpc": "2.0", "id": 1, "result": {"pong": true}})), + ]; + let foreground: Foreground = Box::new(move |peer| { + Box::pin(async move { + peer.request(Ping).await?; + Ok(()) + }) + }); + + let (outcome, seen) = against(script, foreground).await; + + outcome.unwrap(); + assert_eq!(seen, vec!["notice".to_owned(), "permission".to_owned()]); +} + +#[tokio::test] +async fn a_script_that_expects_another_method_reports_both() { + let script = vec![jp(json!({"jsonrpc": "2.0", "id": 1, "method": "ping"}))]; + let foreground: Foreground = Box::new(move |peer| { + Box::pin(async move { + peer.request(Pong).await?; + Ok(()) + }) + }); + + let (outcome, _) = against(script, foreground).await; + + assert_eq!( + outcome.unwrap_err().message, + "the recording has `ping` where JP sent `pong`" + ); +} + +#[tokio::test] +async fn a_script_that_runs_out_says_so() { + let script = vec![ + jp(json!({"jsonrpc": "2.0", "id": 1, "method": "ping"})), + agent(json!({"jsonrpc": "2.0", "id": 1, "result": null})), + ]; + let foreground: Foreground = Box::new(move |peer| { + Box::pin(async move { + peer.request(Ping).await?; + peer.request(Pong).await?; + Ok(()) + }) + }); + + let (outcome, _) = against(script, foreground).await; + + assert_eq!( + outcome.unwrap_err().message, + "JP sent `pong` after the recording ended" + ); +} + +#[test] +fn a_missing_recording_names_the_path_it_wanted() { + let error = read("no-such-conversation").unwrap_err(); + + assert!( + error.starts_with("Recording not found at ") + && error.contains("acp/no-such-conversation.jsonl"), + "{error}" + ); +} + +#[test] +fn a_recorded_conversation_reads_back_as_it_was_observed() { + let directory = camino_tempfile::tempdir().unwrap(); + let path = directory.path().join("nested/traffic.jsonl"); + let tap = recorder(path.as_std_path()); + + tap.observe(Side::Jp, &json!({"id": 1, "method": "ping"})); + tap.observe(Side::Agent, &json!({"id": 1, "result": null})); + + let recorded = parse(&std::fs::read_to_string(&path).unwrap()).unwrap(); + + assert_eq!(recorded.len(), 2); + assert_eq!(recorded[0].from, Side::Jp); + assert_eq!(recorded[0].message, json!({"id": 1, "method": "ping"})); + assert_eq!(recorded[1].from, Side::Agent); + assert_eq!(recorded[1].message, json!({"id": 1, "result": null})); +} + +/// The recorder and the replayer are two halves of one format, written apart. +/// Recording a live exchange and then replaying the file has to reach the +/// foreground with the answer the live agent gave, or one half is writing what +/// the other cannot read. +#[tokio::test] +async fn what_the_recorder_writes_is_what_the_replayer_reads() { + let directory = camino_tempfile::tempdir().unwrap(); + let path = directory.path().join("traffic.jsonl"); + + // An agent that answers whatever it is asked, over a real pipe, observed by + // the same tap the production path installs under `RECORD`. + let (jp_writes, agent_reads) = tokio::io::duplex(1 << 16); + let (agent_writes, jp_reads) = tokio::io::duplex(1 << 16); + tokio::spawn(async move { + use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _}; + + let mut lines = tokio::io::BufReader::new(agent_reads).lines(); + let mut writes = agent_writes; + while let Ok(Some(line)) = lines.next_line().await { + let asked: Value = serde_json::from_str(&line).unwrap(); + let reply = json!({"jsonrpc": "2.0", "id": asked["id"], "result": {"pong": true}}); + let mut line = serde_json::to_vec(&reply).unwrap(); + line.push(b'\n'); + writes.write_all(&line).await.unwrap(); + writes.flush().await.unwrap(); + } + }); + + let live = Arc::new(Mutex::new(None)); + timeout( + Duration::from_secs(5), + drive( + jp_writes, + jp_reads, + recorder(path.as_std_path()), + handler(Value::Null, Arc::default()), + ping_into(live.clone()), + ), + ) + .await + .expect("the recorded connection did not settle") + .unwrap(); + + let replayed = Arc::new(Mutex::new(None)); + let script = parse(&std::fs::read_to_string(&path).unwrap()).unwrap(); + let (outcome, _) = against(script, ping_into(replayed.clone())).await; + outcome.unwrap(); + + let live = live.lock().unwrap_or_else(PoisonError::into_inner).take(); + assert_eq!(live, Some(json!({"pong": true}))); + assert_eq!( + replayed + .lock() + .unwrap_or_else(PoisonError::into_inner) + .take(), + live + ); +} + +/// A query opens a connection per request, and all of them belong in one +/// recording: a per-connection truncation would leave only the last. +#[test] +fn successive_connections_accumulate_in_one_recording() { + let directory = camino_tempfile::tempdir().unwrap(); + let path = directory.path().join("traffic.jsonl"); + + let first = recorder(path.as_std_path()); + first.observe(Side::Jp, &json!({"method": "one"})); + let second = recorder(path.as_std_path()); + second.observe(Side::Jp, &json!({"method": "two"})); + // Interleaved, since a connection stays open while the next one starts. + first.observe(Side::Agent, &json!({"result": "one"})); + + let recorded = parse(&std::fs::read_to_string(&path).unwrap()).unwrap(); + + assert_eq!( + recorded + .iter() + .map(|entry| (entry.connection, entry.message.clone())) + .collect::>(), + [ + (0, json!({"method": "one"})), + (1, json!({"method": "two"})), + (0, json!({"result": "one"})), + ] + ); +} + +/// A recording arrives interleaved, since one connection stays open while the +/// next begins. +/// Each script has to come back whole and in order regardless. +#[test] +fn a_recording_splits_into_one_script_per_connection() { + let second = |message| Framed { + connection: 1, + from: Side::Jp, + message, + }; + let script = vec![ + jp(json!({"method": "first-a"})), + second(json!({"method": "second-a"})), + jp(json!({"method": "first-b"})), + second(json!({"method": "second-b"})), + ]; + + let scripts = connections(script); + + assert_eq!(scripts.len(), 2); + assert_eq!( + scripts[0] + .iter() + .map(|entry| entry.message["method"].clone()) + .collect::>(), + [json!("first-a"), json!("first-b")] + ); + assert_eq!( + scripts[1] + .iter() + .map(|entry| entry.message["method"].clone()) + .collect::>(), + [json!("second-a"), json!("second-b")] + ); +} + +#[test] +fn an_empty_recording_splits_into_no_scripts() { + assert!(connections(Vec::new()).is_empty()); +} + +/// A recording is committed, so what identifies the account that made it and +/// the machine it ran on has to be gone before it reaches the file, not after +/// somebody remembers. +#[test] +fn what_identifies_the_recorder_never_reaches_the_file() { + let directory = camino_tempfile::tempdir().unwrap(); + let path = directory.path().join("traffic.jsonl"); + let tap = recorder(path.as_std_path()); + + tap.observe( + Side::Agent, + &json!({ + "method": "_auth/status_update", + "params": {"authStatus": {"kind": "account", "account": { + "email": "someone@example.com", + "organization": "Example Inc", + "plan": "Claude Max", + }}}, + }), + ); + tap.observe( + Side::Jp, + &json!({"method": "session/load", "params": {"cwd": "/home/someone/work"}}), + ); + tap.observe( + Side::Agent, + &json!({"method": "_claude/sdkMessage", "params": {"message": {"cwd": "/home/someone/work"}}}), + ); + + let contents = std::fs::read_to_string(&path).unwrap(); + let recorded = parse(&contents).unwrap(); + + assert_eq!( + recorded[0].message["params"]["authStatus"]["account"], + json!({ + "email": "[redacted]", + "organization": "[redacted]", + // The plan sits beside the two redacted fields and says nothing + // about who holds it, so losing it would cost the recording detail + // for nothing. + "plan": "Claude Max", + }) + ); + assert_eq!(recorded[1].message["params"]["cwd"], json!("[redacted]")); + assert_eq!( + recorded[2].message["params"]["message"]["cwd"], + json!("[redacted]") + ); + assert!(!contents.contains("someone"), "{contents}"); + assert!(!contents.contains("Example Inc"), "{contents}"); +} + +/// A message that simply lacks a redacted field is the common case, and has to +/// pass through rather than grow one. +#[test] +fn redaction_does_not_invent_fields_a_message_lacks() { + let mut message = json!({"method": "initialize", "params": {"protocolVersion": 1}}); + let before = message.clone(); + + for path in SENSITIVE { + redact(&mut message, path); + } + + assert_eq!(message, before); +} + +#[test] +fn a_malformed_line_is_reported_by_number() { + let contents = "{\"from\":\"jp\",\"message\":{}}\n\nnot json\n"; + + let error = parse(contents).unwrap_err(); + + assert!( + error.starts_with("line 3: not a framed message: "), + "{error}" + ); +} + +#[test] +fn a_framed_message_round_trips_through_its_line() { + let line = + serde_json::to_string(&agent(json!({"jsonrpc": "2.0", "method": "notice"}))).unwrap(); + + assert_eq!( + line, + r#"{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"notice"}}"# + ); + + let parsed: Framed = serde_json::from_str(&line).unwrap(); + assert_eq!(parsed.from, Side::Agent); + assert_eq!(parsed.message["method"], "notice"); +} diff --git a/crates/jp_llm/src/provider/anthropic/acp/options.rs b/crates/jp_llm/src/provider/anthropic/acp/options.rs new file mode 100644 index 000000000..445300d50 --- /dev/null +++ b/crates/jp_llm/src/provider/anthropic/acp/options.rs @@ -0,0 +1,172 @@ +//! Qualified Claude SDK options assembled from a resolved JP request. + +use std::collections::BTreeMap; + +use async_anthropic::types::{Effort, ExtendedThinking}; +use jp_config::{assistant::request::CachePolicy, model::id::Name}; +use serde::Serialize; +use serde_json::{Map, Value}; + +use super::transcript::PreparedRequest; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Extension<'a> { + claude_code: ClaudeCode<'a>, +} + +#[derive(Serialize)] +struct ClaudeCode<'a> { + #[serde(rename = "emitRawSDKMessages")] + emit_raw_sdk_messages: bool, + options: Options<'a>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Options<'a> { + system_prompt: CustomPrompt<'a>, + model: &'a Name, + #[serde(skip_serializing_if = "Option::is_none")] + effort: Option<&'a Effort>, + #[serde(skip_serializing_if = "Option::is_none")] + thinking: Option, + tools: [(); 0], + allowed_tools: [(); 0], + strict_mcp_config: bool, + setting_sources: [(); 0], + settings: Settings, + persist_session: bool, + env: &'a BTreeMap, + #[serde(skip_serializing_if = "Option::is_none")] + output_format: Option>, +} + +#[derive(Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum CustomPrompt<'a> { + Custom { prompt: &'a str, snapshot: bool }, +} + +#[derive(Serialize)] +#[serde( + tag = "type", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +enum Thinking { + Enabled { budget_tokens: u32 }, + Adaptive, + Disabled, +} + +#[derive(Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum OutputFormat<'a> { + JsonSchema { schema: &'a Map }, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Settings { + disable_all_hooks: bool, + auto_memory_enabled: bool, + permissions: Permissions, +} + +#[derive(Serialize)] +struct Permissions { + ask: [&'static str; 1], +} + +pub(super) fn metadata( + prepared: &PreparedRequest, + env: &BTreeMap, +) -> Result { + let thinking = prepared.thinking.as_ref().map(|thinking| match thinking { + ExtendedThinking::Enabled { budget_tokens, .. } => Thinking::Enabled { + budget_tokens: *budget_tokens, + }, + ExtendedThinking::Adaptive { .. } => Thinking::Adaptive, + ExtendedThinking::Disabled => Thinking::Disabled, + }); + serde_json::to_value(Extension { + claude_code: ClaudeCode { + emit_raw_sdk_messages: true, + options: Options { + system_prompt: CustomPrompt::Custom { + prompt: &prepared.system_prompt, + snapshot: false, + }, + model: &prepared.model, + effort: prepared.effort.as_ref(), + thinking, + tools: [], + allowed_tools: [], + strict_mcp_config: true, + setting_sources: [], + settings: Settings { + disable_all_hooks: true, + auto_memory_enabled: false, + // Even unattended JP tools need the adapter callback for + // correlation; the JP MCP Host decides whether to prompt. + permissions: Permissions { + ask: ["mcp__jp__*"], + }, + }, + persist_session: !prepared.history.is_empty(), + env, + output_format: prepared + .schema + .as_ref() + .map(|schema| OutputFormat::JsonSchema { schema }), + }, + }, + }) +} + +/// The adapter's wall-clock ceiling for one tool call, in milliseconds. +/// +/// `MCP_TOOL_TIMEOUT` has no "off" spelling, so the ceiling is a number large +/// enough that no prompt outlives it: about 24 days, the most a 32-bit +/// millisecond timer holds. +const NO_TIMEOUT: i32 = i32::MAX; + +pub(super) fn environment( + prepared: &PreparedRequest, + cache: CachePolicy, +) -> BTreeMap { + let mut environment = BTreeMap::from([ + ("CLAUDE_CODE_DISABLE_AUTO_MEMORY".into(), "1".into()), + ("DISABLE_AUTO_COMPACT".into(), "1".into()), + ("CLAUDE_CODE_DISABLE_BACKGROUND_TASKS".into(), "1".into()), + ("CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS".into(), "0".into()), + ("ENABLE_TOOL_SEARCH".into(), "false".into()), + ("MAX_MCP_OUTPUT_TOKENS".into(), "100000".into()), + // A JP tool call stays silent while its approval prompt is open, and + // the adapter's two per-call timers measure wall-clock time whether or + // not JP is running: a closed laptop looks exactly like a hung server. + // Both would otherwise abort the call and hand the model a failure for + // a question nobody has answered yet. + // + // Disabling the idle check rather than sending progress notifications + // is what survives suspension — a heartbeat only resets the timer if it + // arrives, and a suspended process sends nothing. + // + // Deciding when to stop waiting is JP's job: the interrupt handler + // cancels a call the user abandons, and the MCP Host holds the reply + // the service is parked on until then. + ("CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT".into(), "0".into()), + ("MCP_TOOL_TIMEOUT".into(), NO_TIMEOUT.to_string()), + ]); + if let Some(max_tokens) = prepared.max_tokens { + environment.insert( + "CLAUDE_CODE_MAX_OUTPUT_TOKENS".into(), + max_tokens.to_string(), + ); + } + if cache == CachePolicy::Off { + environment.insert("DISABLE_PROMPT_CACHING".into(), "1".into()); + } + environment +} diff --git a/crates/jp_llm/src/provider/anthropic/acp/process.rs b/crates/jp_llm/src/provider/anthropic/acp/process.rs new file mode 100644 index 000000000..fdb345bba --- /dev/null +++ b/crates/jp_llm/src/provider/anthropic/acp/process.rs @@ -0,0 +1,160 @@ +//! ACP process lifecycle using Unix process groups or Windows job objects. + +use std::{ + collections::VecDeque, + env, io, + ops::{Deref, DerefMut}, + process::Stdio, + time::Duration, +}; + +#[cfg(windows)] +use process_wrap::tokio::JobObject; +#[cfg(unix)] +use process_wrap::tokio::ProcessGroup; +use process_wrap::tokio::{ChildWrapper, CommandWrap, KillOnDrop}; +use serde_json::json; +use tokio::{ + io::{AsyncRead, AsyncReadExt as _}, + process::Command, +}; +use tracing::debug; + +use super::{ + removes_variable, + rpc::{self, Handler, Peer, RpcError, Tap}, +}; + +pub(super) fn command() -> Command { + #[cfg(windows)] + let mut command = { + // npm installs a .cmd shim on Windows. No prompt or model data is + // interpolated here; those values travel over the ACP connection. + let mut command = Command::new("cmd.exe"); + command.args(["/D", "/C", "claude-agent-acp.cmd"]); + command + }; + #[cfg(not(windows))] + let mut command = Command::new("claude-agent-acp"); + for (name, _) in env::vars_os() { + if name.to_str().is_some_and(removes_variable) { + command.env_remove(name); + } + } + command +} + +pub(super) fn spawn(command: Command) -> io::Result { + let mut command = CommandWrap::from(command); + command.wrap(KillOnDrop); + #[cfg(unix)] + command.wrap(ProcessGroup::leader()); + #[cfg(windows)] + command.wrap(JobObject); + command.spawn().map(Child) +} + +/// Drop uses the wrapper's termination method, not just Tokio's direct-child +/// kill-on-drop flag, so Unix descendants are terminated too. +pub(super) struct Child(Box); + +impl Deref for Child { + type Target = dyn ChildWrapper; + fn deref(&self) -> &Self::Target { + self.0.as_ref() + } +} + +impl DerefMut for Child { + fn deref_mut(&mut self) -> &mut Self::Target { + self.0.as_mut() + } +} + +impl Drop for Child { + fn drop(&mut self) { + if let Err(error) = self.start_kill() { + debug!(%error, "ACP child cleanup"); + } + } +} + +/// Spawn the adapter and run one ACP connection against its stdio. +/// +/// `foreground` drives the request sequence; `handler` answers everything the +/// adapter initiates. +/// The connection ends when `foreground` returns, when the adapter exits, or +/// when either side fails. +/// +/// A failure carries the tail of the adapter's stderr, which is usually the +/// only account of why a Node process died. +pub(super) async fn run( + command: Command, + tap: Tap, + handler: Handler, + foreground: F, +) -> Result<(), RpcError> +where + F: FnOnce(Peer) -> Fut, + Fut: Future>, +{ + let mut command = command; + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = spawn(command).map_err(RpcError::into_internal_error)?; + let stdin = child.stdin().take().expect("stdin is piped"); + let stdout = child.stdout().take().expect("stdout is piped"); + let stderr = child.stderr().take().expect("stderr is piped"); + let protocol = rpc::drive(stdin, stdout, tap, handler, foreground); + tokio::pin!(protocol); + let completion = async { + let result = tokio::select! { + result = &mut protocol => { + match tokio::time::timeout(Duration::from_secs(1), child.wait()).await { + Ok(Ok(status)) if result.is_ok() && !status.success() => Err(RpcError::internal_error().data(json!({"exit_code":status.code(),"status":status.to_string()}))), + _ => result, + } + }, + status = child.wait() => match status { + Ok(status) if status.success() => tokio::time::timeout(Duration::from_secs(1), &mut protocol).await + .map_err(RpcError::into_internal_error).and_then(|result| result), + Ok(status) => Err(RpcError::internal_error().data(json!({"exit_code":status.code(),"status":status.to_string()}))), + Err(error) => Err(RpcError::into_internal_error(error)), + } + }; + if let Err(error) = child.start_kill() { + debug!(%error, "ACP process cleanup"); + } + drop(tokio::time::timeout(Duration::from_secs(1), child.wait()).await); + result + }; + let (result, stderr) = tokio::join!(completion, stderr_tail(stderr)); + let stderr = stderr.map_err(RpcError::into_internal_error)?; + result.map_err(|mut error| { + if stderr.is_empty() { + return error; + } + let cause = error.data.take(); + error.data(json!({"cause":cause,"stderr":String::from_utf8_lossy(&stderr)})) + }) +} + +async fn stderr_tail(mut stderr: impl AsyncRead + Unpin) -> io::Result> { + let mut tail = VecDeque::new(); + let mut buffer = [0; 8192]; + loop { + let size = stderr.read(&mut buffer).await?; + if size == 0 { + return Ok(tail.into()); + } + tail.extend(&buffer[..size]); + let excess = tail.len().saturating_sub(65536); + tail.drain(..excess); + } +} + +#[cfg(test)] +#[path = "process_tests.rs"] +mod tests; diff --git a/crates/jp_llm/src/provider/anthropic/acp/process_tests.rs b/crates/jp_llm/src/provider/anthropic/acp/process_tests.rs new file mode 100644 index 000000000..8fab0370e --- /dev/null +++ b/crates/jp_llm/src/provider/anthropic/acp/process_tests.rs @@ -0,0 +1,88 @@ +use tokio::io::{AsyncBufReadExt as _, BufReader}; + +use super::*; + +#[test] +fn adapter_launcher_uses_the_platform_entry_point() { + let command = command(); + #[cfg(windows)] + { + assert_eq!(command.as_std().get_program(), "cmd.exe"); + assert_eq!(command.as_std().get_args().collect::>(), [ + "/D", + "/C", + "claude-agent-acp.cmd" + ]); + } + #[cfg(unix)] + assert_eq!(command.as_std().get_program(), "claude-agent-acp"); +} + +#[tokio::test] +async fn process_failure_retains_stderr() { + #[cfg(unix)] + let mut command = Command::new("sh"); + #[cfg(unix)] + command.args(["-c", "printf fixture-error >&2; exit 7"]); + #[cfg(windows)] + let mut command = Command::new("cmd.exe"); + #[cfg(windows)] + command.args(["/D", "/C", "echo fixture-error >&2 & exit /B 7"]); + // The adapter never speaks: the foreground request cannot be answered, so + // the exit status is what ends the connection. + let error = tokio::time::timeout( + Duration::from_secs(5), + run( + command, + Tap::none(), + Box::new(|_| Box::pin(async { Ok(serde_json::Value::Null) })), + |_peer| async { Ok(()) }, + ), + ) + .await + .unwrap() + .unwrap_err(); + // Shell line endings differ by OS. + let detail = error.data.unwrap(); + assert_eq!(detail["cause"]["exit_code"], 7); + assert_eq!(detail["stderr"].as_str().unwrap().trim(), "fixture-error"); +} + +#[tokio::test] +async fn dropping_the_process_closes_its_open_output_pipe() { + #[cfg(unix)] + let mut command = Command::new("sh"); + #[cfg(unix)] + command.args(["-c", "sh -c 'echo ready; exec sleep 60' & wait"]); + #[cfg(windows)] + let mut command = Command::new("cmd.exe"); + #[cfg(windows)] + command.args(["/D", "/C", "echo ready & set /P pending="]); + command.stdin(Stdio::piped()).stdout(Stdio::piped()); + let mut child = spawn(command).unwrap(); + let stdin = child.stdin().take().unwrap(); + let mut stdout = BufReader::new(child.stdout().take().unwrap()); + let mut ready = String::new(); + tokio::time::timeout(Duration::from_secs(5), stdout.read_line(&mut ready)) + .await + .unwrap() + .unwrap(); + assert_eq!(ready.trim(), "ready"); + assert!(child.try_wait().unwrap().is_none()); + drop(child); + let mut rest = Vec::new(); + tokio::time::timeout(Duration::from_secs(5), stdout.read_to_end(&mut rest)) + .await + .unwrap() + .unwrap(); + assert_eq!(rest, b""); + // Retain stdin until after EOF so closing it cannot make the fixture exit. + drop(stdin); +} + +#[tokio::test] +async fn stderr_capture_drains_but_keeps_only_the_tail() { + let input = vec![b'x'; 70000]; + let output = stderr_tail(input.as_slice()).await.unwrap(); + assert_eq!(output, vec![b'x'; 65536]); +} diff --git a/crates/jp_llm/src/provider/anthropic/acp/protocol.rs b/crates/jp_llm/src/provider/anthropic/acp/protocol.rs new file mode 100644 index 000000000..611688939 --- /dev/null +++ b/crates/jp_llm/src/provider/anthropic/acp/protocol.rs @@ -0,0 +1,618 @@ +//! Typed Claude adapter notifications and response translation. + +use std::collections::{BTreeMap, HashMap, HashSet}; + +use async_anthropic::types::{CreateMessagesResponse, MessageContent, MessagesStreamEvent, Usage}; +use indexmap::IndexSet; +use jp_config::model::id::Name; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use tracing::trace; + +use super::{ + Error, + schema::{ + PermissionOptionKind, RequestPermissionOutcome, RequestPermissionRequest, + RequestPermissionResponse, SessionId, SessionNotification, SessionUpdate, ToolCallStatus, + }, + transcript::tool_name, + usage::{ModelUsage, RuntimeUsage, UsageLedger}, +}; +use crate::{ + error::{StreamError, StreamErrorKind, looks_like_context_window_error}, + event::{Event, EventPart, FinishReason, ToolCallPart}, + provider::anthropic::map_event, +}; + +/// The adapter's effective authentication, independent of JP's token store. +/// +/// A Claude Code extension rather than an ACP method, so its name is spelled +/// here instead of coming from the schema's method tables. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct AuthUpdate { + pub auth_status: AgentAuth, +} + +impl AuthUpdate { + pub(super) const METHOD: &'static str = "_auth/status_update"; +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub(super) enum AgentAuth { + Account { + account: Account, + }, + #[serde(other)] + Other, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(super) struct Account { + pub plan: String, +} + +impl AgentAuth { + pub(super) fn is_subscription(&self) -> bool { + matches!(self, Self::Account { account } if matches!(account.plan.as_str(), "Claude Pro" | "Claude Max" | "pro" | "max")) + } +} + +/// One message from the Claude SDK behind the adapter. +/// +/// A Claude Code extension rather than an ACP method, so its name is spelled +/// here instead of coming from the schema's method tables. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct SdkNotification { + pub session_id: SessionId, + pub message: SdkMessage, +} + +impl SdkNotification { + pub(super) const METHOD: &'static str = "_claude/sdkMessage"; +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(super) enum SdkMessage { + System { + subtype: String, + #[serde(default)] + tools: Vec, + }, + StreamEvent { + event: MessagesStreamEvent, + #[serde(default)] + parent_tool_use_id: Option, + }, + User { + message: UserMessage, + }, + Assistant { + message: CreateMessagesResponse, + #[serde(default)] + error: Option, + #[serde(default)] + parent_tool_use_id: Option, + }, + Result { + #[serde(default)] + usage: Option, + #[serde(default, rename = "modelUsage")] + model_usage: BTreeMap, + #[serde(default)] + total_cost_usd: Option, + subtype: String, + is_error: bool, + #[serde(default)] + errors: Vec, + #[serde(default)] + structured_output: Option, + #[serde(default)] + stop_reason: Option, + #[serde(default)] + refusal: Option, + }, + #[serde(other)] + Other, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(super) struct UserMessage { + content: UserContent, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +enum UserContent { + Blocks(Vec), + Text(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum UserBlock { + ToolResult { + tool_use_id: String, + #[serde(default)] + content: Option, + }, + #[serde(other)] + Other, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +enum ResultContent { + Text(String), + Blocks(Vec), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ResultBlock { + Text { + text: String, + }, + #[serde(other)] + Other, +} + +impl ResultContent { + fn is_file_reference(&self) -> bool { + let persisted = |text: &str| text.trim_start().starts_with(""); + match self { + Self::Text(text) => persisted(text), + Self::Blocks(blocks) => blocks + .iter() + .any(|block| matches!(block, ResultBlock::Text { text } if persisted(text))), + } + } +} + +/// One request's translation state. +/// Replay never enters this state as live output. +pub(super) struct State { + pub session: Option, + pub live: bool, + pub authenticated: bool, + pub pending_tools: HashSet, + model: Name, + tools: HashMap, + structured: bool, + inventory_checked: bool, + seen_calls: HashSet, + observed_names: HashMap, + ignored: HashSet, + index_base: usize, + next_index: usize, + pub final_events: Option>, + /// Preserve notification failures across the JSON-RPC error boundary. + pub failure: Option, + usage: UsageLedger, + current_message: Option, + open_tool_blocks: HashSet, + pending_previews: IndexSet, +} + +struct MessageIdentity { + id: String, + model: String, +} + +impl State { + pub(super) fn new(model: Name, tools: impl Iterator, structured: bool) -> Self { + Self { + session: None, + live: false, + authenticated: false, + pending_tools: HashSet::new(), + model, + tools: tools.map(|name| (tool_name(&name), name)).collect(), + structured, + inventory_checked: false, + seen_calls: HashSet::new(), + observed_names: HashMap::new(), + ignored: HashSet::new(), + index_base: 0, + next_index: 0, + final_events: None, + failure: None, + usage: UsageLedger::default(), + current_message: None, + open_tool_blocks: HashSet::new(), + pending_previews: IndexSet::new(), + } + } + + pub(super) fn permission( + &mut self, + request: RequestPermissionRequest, + ) -> Result<(RequestPermissionResponse, Vec), StreamError> { + if !self.live + || !self.authenticated + || !self.inventory_checked + || self.session.as_ref() != Some(&request.session_id) + { + return Err(StreamError::other( + "ACP requested tool execution outside an authenticated live request", + )); + } + let call = request.tool_call; + let id = call.tool_call_id.to_string(); + + let native_name = call + .meta + .as_ref() + .and_then(|meta| meta.get("claudeCode")) + .and_then(|value| value.get("toolName")) + .and_then(Value::as_str) + .or_else(|| self.observed_names.get(&id).map(String::as_str)) + .ok_or_else(|| { + StreamError::other("ACP permission request has no canonical tool name") + })?; + let name = self + .tools + .get(native_name) + .ok_or_else(|| { + StreamError::other(format!("ACP requested an unconfigured tool: {native_name}")) + })? + .clone(); + if !self.seen_calls.insert(id.clone()) { + return Err(StreamError::other( + "ACP repeated a dispatched tool-call identifier", + )); + } + let arguments = call + .raw_input + .and_then(|input| input.as_object().cloned()) + .ok_or_else(|| StreamError::other("ACP tool arguments must be a JSON object"))?; + let option = request + .options + .into_iter() + .find(|option| option.kind == PermissionOptionKind::AllowOnce) + .ok_or_else(|| { + StreamError::other("ACP did not offer one-call delegation to JP's MCP server") + })?; + self.pending_previews.shift_remove(&id); + self.pending_tools.insert(id.clone()); + let index = self.next_index; + self.next_index += 1; + let events = vec![ + Event::Part { + index, + part: EventPart::ToolCall(ToolCallPart::Start { id, name }), + metadata: Map::new(), + }, + Event::Part { + index, + part: EventPart::ToolCall(ToolCallPart::ArgumentChunk( + Value::Object(arguments).to_string(), + )), + metadata: Map::new(), + }, + Event::flush(index), + Event::Finished(FinishReason::Completed), + ]; + Ok(( + RequestPermissionResponse { + outcome: RequestPermissionOutcome::Selected { + option_id: option.option_id, + }, + }, + events, + )) + } + + /// Tool arguments may be buffered by the provider before any delta arrives. + /// Execution and Host interactions can also outlive the stream idle + /// timeout. + pub(super) fn has_tool_activity(&self) -> bool { + !self.open_tool_blocks.is_empty() || !self.pending_tools.is_empty() + } + + fn retire_previews(&mut self) -> Vec { + self.open_tool_blocks.clear(); + self.pending_previews + .drain(..) + .map(|id| { + trace!(tool_call_id = %id, "Removing abandoned ACP pending-tool display"); + Event::ToolCallPendingEnd { id } + }) + .collect() + } + + pub(super) fn usage_snapshot(&self) -> Value { + self.session + .as_ref() + .map_or(Value::Null, |id| self.usage.snapshot(&id.0)) + } + + pub(super) fn observe(&mut self, notification: SessionNotification) { + if !self.live || self.session.as_ref() != Some(¬ification.session_id) { + return; + } + let (id, meta) = match notification.update { + SessionUpdate::ToolCall(call) => (call.tool_call_id.to_string(), call.meta), + SessionUpdate::ToolCallUpdate(update) => { + let id = update.tool_call_id.to_string(); + if matches!( + update.status, + Some(ToolCallStatus::Completed | ToolCallStatus::Failed) + ) { + self.pending_tools.remove(&id); + } + (id, update.meta) + } + SessionUpdate::Other => return, + }; + if let Some(name) = meta + .as_ref() + .and_then(|meta| meta.get("claudeCode")) + .and_then(|value| value.get("toolName")) + .and_then(Value::as_str) + { + self.observed_names.insert(id, name.to_owned()); + } + } + + #[expect( + clippy::too_many_lines, + reason = "Keep SDK message variants and their state updates in one dispatch point" + )] + pub(super) fn sdk(&mut self, notification: SdkNotification) -> Result, StreamError> { + if !self.live || self.session.as_ref() != Some(¬ification.session_id) { + return Ok(vec![]); + } + match notification.message { + SdkMessage::System { subtype, tools } if subtype == "init" => { + let expected: HashSet<_> = self.tools.keys().map(String::as_str).collect(); + let actual: HashSet<_> = tools + .iter() + .map(String::as_str) + .filter(|name| !(self.structured && *name == "StructuredOutput")) + .collect(); + if actual != expected { + return Err(StreamError::other( + "Claude Code's actual tool inventory differs from JP's configured tools", + )); + } + self.inventory_checked = true; + Ok(vec![]) + } + SdkMessage::User { message } => { + if let UserContent::Blocks(blocks) = message.content { + for block in blocks { + if let UserBlock::ToolResult { + tool_use_id, + content, + } = block + { + if content + .as_ref() + .is_some_and(ResultContent::is_file_reference) + { + return Err(StreamError::other(format!( + "Claude Code replaced tool result {tool_use_id} with a file \ + reference; the ACP flow cannot preserve this result inline" + ))); + } + self.pending_tools.remove(&tool_use_id); + } + } + } + Ok(vec![]) + } + SdkMessage::Assistant { + message, + error, + parent_tool_use_id: None, + } => { + if let Some(error) = error { + let detail = message + .content + .iter() + .filter_map(|block| match block { + MessageContent::Text(text) => Some(text.text.as_str()), + _ => None, + }) + .collect::>() + .join("\n"); + let rejection = match error.as_str() { + "max_output_tokens" => { + return Err(StreamError::new( + StreamErrorKind::MaxOutputTokens, + format!("Claude Code exhausted its output-token limit: {detail}"), + ) + .with_hint( + "Configure assistant.model.parameters.max_tokens to override the \ + runtime output limit.", + )); + } + "model_not_found" => Error::ModelUnavailable { + model: self.model.clone(), + detail, + }, + "invalid_request" => Error::RequestRejected { + model: self.model.clone(), + detail, + }, + _ => { + return Err(StreamError::other(format!( + "Claude Code {error}: {detail}" + ))); + } + }; + let kind = if matches!(&rejection, Error::RequestRejected { detail, .. } if looks_like_context_window_error(detail)) + { + StreamErrorKind::ContextWindowExceeded + } else { + StreamErrorKind::Other + }; + return Err( + StreamError::new(kind, rejection.to_string()).with_source(rejection) + ); + } + self.usage.observe(&message); + Ok(vec![]) + } + SdkMessage::StreamEvent { + event, + parent_tool_use_id: None, + } => self.stream_event(event), + SdkMessage::Result { + usage, + model_usage, + total_cost_usd, + subtype, + is_error, + errors, + structured_output, + stop_reason, + refusal, + } => { + self.usage.set_runtime(RuntimeUsage { + usage, + model_usage, + estimated_cost_usd: total_cost_usd, + }); + let mut events = self.retire_previews(); + let finish = if stop_reason.as_deref() == Some("refusal") { + FinishReason::Refused { + category: refusal + .as_ref() + .and_then(|r| r.get("category")) + .and_then(Value::as_str) + .map(str::to_owned), + explanation: refusal + .as_ref() + .and_then(|r| r.get("explanation")) + .and_then(Value::as_str) + .map(str::to_owned), + } + } else if stop_reason.as_deref() == Some("max_tokens") { + FinishReason::MaxTokens + } else if is_error || subtype != "success" { + let detail = errors.join("\n"); + return Err(StreamError::other(if detail.is_empty() { + format!("Claude Code request failed: {subtype}") + } else { + format!("Claude Code request failed ({subtype}): {detail}") + })); + } else { + if !self.inventory_checked { + return Err(StreamError::other( + "Claude Code did not report its tool inventory", + )); + } + if self.structured { + let data = structured_output.ok_or_else(|| { + StreamError::other("Claude Code returned no structured result") + })?; + events.push(Event::Part { + index: self.next_index, + part: EventPart::Structured(data.to_string()), + metadata: Map::new(), + }); + events.push(Event::flush(self.next_index)); + self.next_index += 1; + } + FinishReason::Completed + }; + events.push(Event::Finished(finish)); + self.final_events = Some(events); + Ok(vec![]) + } + _ => Ok(vec![]), + } + } + + fn stream_event(&mut self, event: MessagesStreamEvent) -> Result, StreamError> { + let mut events = Vec::new(); + if let MessagesStreamEvent::ContentBlockStop { index } = &event + && self.open_tool_blocks.remove(index) + { + trace!(index, "Finished receiving ACP tool arguments"); + } + match &event { + MessagesStreamEvent::MessageStart { message, usage } => { + // A replacement response cannot leave the previous attempt's + // uncommitted tool identities on the Host's preparing row. + events.extend(self.retire_previews()); + self.index_base = self.next_index; + self.current_message = Some(MessageIdentity { + id: message.id.clone(), + model: message.model.clone(), + }); + if let Some(usage) = message.usage.as_ref().or(usage.as_ref()) { + self.usage.observe_usage(&message.id, &message.model, usage); + } + } + MessagesStreamEvent::MessageDelta { delta, usage } => { + if let (Some(message), Some(usage)) = (&self.current_message, usage) { + self.usage.observe_usage(&message.id, &message.model, usage); + } + if matches!(delta.stop_reason.as_deref(), Some("max_tokens" | "refusal")) { + events.extend(self.retire_previews()); + } + } + MessagesStreamEvent::MessageStop => { + self.current_message = None; + self.index_base = self.next_index; + self.ignored.clear(); + self.open_tool_blocks.clear(); + return Ok(vec![]); + } + MessagesStreamEvent::ContentBlockStart { + index, + content_block, + } => { + self.next_index = self.next_index.max(self.index_base + index + 1); + if let MessageContent::ToolUse(call) = content_block { + self.open_tool_blocks.insert(*index); + trace!(index, tool_call_id = %call.id, "Receiving ACP tool arguments"); + // SDK observations can arrive after delegation; they must + // not reopen a call's completed preparing row. + if let Some(name) = self.tools.get(&call.name) + && !self.seen_calls.contains(&call.id) + && self.pending_previews.insert(call.id.clone()) + { + events.push(Event::ToolCallPending { + id: call.id.clone(), + name: name.clone(), + }); + } + } + if matches!(content_block, MessageContent::ToolUse(_)) + || (self.structured && matches!(content_block, MessageContent::Text(_))) + { + self.ignored.insert(*index); + return Ok(events); + } + } + MessagesStreamEvent::ContentBlockDelta { index, .. } + | MessagesStreamEvent::ContentBlockStop { index } + if self.ignored.contains(index) => + { + return Ok(vec![]); + } + _ => {} + } + for event in map_event(event, false) { + let mut event = event?; + match &mut event { + Event::Part { index, .. } | Event::Flush { index, .. } => *index += self.index_base, + Event::Finished(_) => continue, + _ => {} + } + events.push(event); + } + Ok(events) + } +} + +#[cfg(test)] +#[path = "protocol_tests.rs"] +mod tests; diff --git a/crates/jp_llm/src/provider/anthropic/acp/protocol_tests.rs b/crates/jp_llm/src/provider/anthropic/acp/protocol_tests.rs new file mode 100644 index 000000000..d6df9f5cf --- /dev/null +++ b/crates/jp_llm/src/provider/anthropic/acp/protocol_tests.rs @@ -0,0 +1,359 @@ +use std::error::Error as _; + +use serde_json::json; + +use super::*; +use crate::error::StreamErrorKind; + +#[test] +fn oversized_prompt_uses_the_context_window_error_kind() { + let mut state = state(); + let error = state.sdk(notification(json!({"type":"assistant","error":"invalid_request","message":{"content":[{"type":"text","text":"Prompt is too long"}]}}))).unwrap_err(); + assert_eq!(error.kind, StreamErrorKind::ContextWindowExceeded); + assert!(!error.is_retryable()); + assert_eq!( + error.message(), + "Claude Code rejected the request for model `claude-opus-5`: Prompt is too long" + ); + let source = error.source().unwrap().downcast_ref::().unwrap(); + assert!( + matches!(source, Error::RequestRejected { model, detail } if model.as_ref() == "claude-opus-5" && detail == "Prompt is too long") + ); +} + +#[test] +fn unrelated_invalid_requests_are_not_context_window_errors() { + let mut state = state(); + let error = state.sdk(notification(json!({"type":"assistant","error":"invalid_request","message":{"content":[{"type":"text","text":"Unsupported thinking configuration"}]}}))).unwrap_err(); + assert_eq!(error.kind, StreamErrorKind::Other); + assert_eq!( + error.message(), + "Claude Code rejected the request for model `claude-opus-5`: Unsupported thinking \ + configuration" + ); +} + +fn state() -> State { + let mut state = State::new( + "claude-opus-5".parse().unwrap(), + ["lookup".into()].into_iter(), + false, + ); + state.session = Some("session-fixed".into()); + state.authenticated = true; + state.live = true; + state + .sdk(notification( + json!({"type":"system","subtype":"init","tools":["mcp__jp__lookup"]}), + )) + .unwrap(); + state +} + +fn notification(message: Value) -> SdkNotification { + SdkNotification { + session_id: "session-fixed".into(), + message: serde_json::from_value(message).unwrap(), + } +} + +fn permission() -> RequestPermissionRequest { + serde_json::from_value(json!({ + "sessionId":"session-fixed", + "toolCall":{"toolCallId":"tool-fixed","rawInput":{"path":"README.md"},"_meta":{"claudeCode":{"toolName":"mcp__jp__lookup"}}}, + "options":[{"optionId":"once","name":"Allow","kind":"allow_once"}] + })).unwrap() +} + +#[test] +fn dispatch_uses_permission_identity_and_original_arguments() { + let mut state = state(); + let (response, events) = state.permission(permission()).unwrap(); + assert_eq!( + serde_json::to_value(response).unwrap(), + json!({"outcome":{"outcome":"selected","optionId":"once"}}) + ); + assert_eq!(events, vec![ + Event::Part { + index: 0, + part: EventPart::ToolCall(ToolCallPart::Start { + id: "tool-fixed".into(), + name: "lookup".into() + }), + metadata: Map::new() + }, + Event::Part { + index: 0, + part: EventPart::ToolCall(ToolCallPart::ArgumentChunk( + r#"{"path":"README.md"}"#.into() + )), + metadata: Map::new() + }, + Event::flush(0), + Event::Finished(FinishReason::Completed) + ]); + let error = state.permission(permission()).unwrap_err(); + assert_eq!( + error.message(), + "ACP repeated a dispatched tool-call identifier" + ); +} + +#[test] +fn tool_only_response_records_usage_without_counting_replay_or_subagents() { + let mut state = state(); + state.live = false; + state.sdk(notification(json!({"type":"assistant","message":{"id":"msg-replay","model":"claude-opus-5","usage":{"input_tokens":999,"output_tokens":999}}}))).unwrap(); + state.live = true; + state.sdk(notification(json!({"type":"assistant","parent_tool_use_id":"parent","message":{"id":"msg-child","model":"claude-haiku-4-5","usage":{"input_tokens":99,"output_tokens":99}}}))).unwrap(); + state.sdk(notification(json!({"type":"assistant","message":{"id":"msg-tool","model":"claude-opus-5","content":[{"type":"tool_use","id":"tool-fixed","name":"mcp__jp__lookup","input":{"path":"README.md"}}],"usage":{"input_tokens":2,"output_tokens":9}}}))).unwrap(); + let (_, events) = state.permission(permission()).unwrap(); + let Event::Flush { metadata, .. } = &events[2] else { + panic!("expected tool flush") + }; + assert!(metadata.is_empty()); + assert_eq!( + state.usage_snapshot(), + json!({"native_session_id":"session-fixed","requests":{"msg-tool":{"model":"claude-opus-5","input_tokens":2,"output_tokens":9}}}) + ); +} + +#[test] +fn permission_uses_the_name_from_the_prior_tool_observation() { + let mut state = state(); + state.observe(serde_json::from_value(json!({"sessionId":"session-fixed","update":{"sessionUpdate":"tool_call","toolCallId":"tool-fixed","title":"Lookup","_meta":{"claudeCode":{"toolName":"mcp__jp__lookup"}}}})).unwrap()); + let mut request = permission(); + request.tool_call.meta = None; + let (_, events) = state.permission(request).unwrap(); + assert_eq!(events[0], Event::Part { + index: 0, + part: EventPart::ToolCall(ToolCallPart::Start { + id: "tool-fixed".into(), + name: "lookup".into() + }), + metadata: Map::new() + }); +} + +#[test] +fn load_replay_does_not_emit_or_authorize_work() { + let mut state = state(); + state.live = false; + let events = state.sdk(notification(json!({"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":"OLD"}}}))).unwrap(); + assert_eq!(events, vec![]); + let error = state.permission(permission()).unwrap_err(); + assert_eq!( + error.message(), + "ACP requested tool execution outside an authenticated live request" + ); +} + +#[test] +fn unexpected_tools_fail_inventory_check() { + let mut state = state(); + let error = state + .sdk(notification( + json!({"type":"system","subtype":"init","tools":["mcp__jp__lookup","Bash"]}), + )) + .unwrap_err(); + assert_eq!( + error.message(), + "Claude Code's actual tool inventory differs from JP's configured tools" + ); +} + +#[test] +fn sdk_tool_observation_is_not_an_execution_request() { + let mut state = state(); + let events = state.sdk(notification(json!({"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"tool-fixed","name":"mcp__jp__lookup","input":{}}}}))).unwrap(); + assert_eq!(events, vec![Event::ToolCallPending { + id: "tool-fixed".into(), + name: "lookup".into() + }]); + assert!(state.has_tool_activity()); + let events = state.sdk(notification(json!({"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{}"}}}))).unwrap(); + assert_eq!(events, vec![]); + assert!(state.has_tool_activity()); + state + .sdk(notification( + json!({"type":"stream_event","event":{"type":"content_block_stop","index":0}}), + )) + .unwrap(); + assert!(!state.has_tool_activity()); +} + +#[test] +fn a_late_sdk_start_cannot_restore_a_delegated_call_to_pending() { + let mut state = state(); + state.permission(permission()).unwrap(); + state.sdk(notification(json!({"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-fixed","content":"done"}]}}))).unwrap(); + let events = state.sdk(notification(json!({"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"tool-fixed","name":"mcp__jp__lookup","input":{}}}}))).unwrap(); + assert_eq!(events, vec![]); +} + +#[test] +fn a_new_response_retracts_abandoned_pending_calls() { + let mut state = state(); + state.sdk(notification(json!({"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"abandoned","name":"mcp__jp__lookup","input":{}}}}))).unwrap(); + let events = state.sdk(notification(json!({"type":"stream_event","event":{"type":"message_start","message":{"id":"retry-response","model":"claude-opus-5","role":"assistant","content":[]}}}))).unwrap(); + assert_eq!(events, vec![Event::ToolCallPendingEnd { + id: "abandoned".into() + }]); + let events = state.sdk(notification(json!({"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"replacement","name":"mcp__jp__lookup","input":{}}}}))).unwrap(); + assert_eq!(events, vec![Event::ToolCallPending { + id: "replacement".into(), + name: "lookup".into() + }]); +} + +#[test] +fn distinct_calls_to_the_same_tool_are_not_collapsed() { + let mut state = state(); + let first = state.sdk(notification(json!({"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"tool-fixed","name":"mcp__jp__lookup","input":{}}}}))).unwrap(); + let second = state.sdk(notification(json!({"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"second","name":"mcp__jp__lookup","input":{}}}}))).unwrap(); + assert_eq!(first, vec![Event::ToolCallPending { + id: "tool-fixed".into(), + name: "lookup".into() + }]); + assert_eq!(second, vec![Event::ToolCallPending { + id: "second".into(), + name: "lookup".into() + }]); + let end = state.sdk(notification(json!({"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"tool_use"}}}))).unwrap(); + assert_eq!(end, vec![]); + state.permission(permission()).unwrap(); + assert_eq!(state.retire_previews(), vec![Event::ToolCallPendingEnd { + id: "second".into() + }]); +} + +#[test] +fn truncation_retracts_an_unfinished_tool_preview() { + let mut state = state(); + state.sdk(notification(json!({"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"truncated","name":"mcp__jp__lookup","input":{}}}}))).unwrap(); + let events = state.sdk(notification(json!({"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"max_tokens"}}}))).unwrap(); + assert_eq!(events, vec![Event::ToolCallPendingEnd { + id: "truncated".into() + }]); + assert!(!state.has_tool_activity()); +} + +#[test] +fn final_usage_delta_wins_over_an_earlier_assistant_snapshot() { + let mut state = state(); + state.sdk(notification(json!({"type":"stream_event","event":{"type":"message_start","message":{"id":"msg-count","model":"claude-opus-5","role":"assistant","content":[],"usage":{"input_tokens":2,"output_tokens":1}}}}))).unwrap(); + state.sdk(notification(json!({"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":7}}}))).unwrap(); + state.sdk(notification(json!({"type":"assistant","message":{"id":"msg-count","model":"claude-opus-5","usage":{"input_tokens":2,"output_tokens":1}}}))).unwrap(); + assert_eq!( + state.usage_snapshot()["requests"]["msg-count"]["output_tokens"], + 7 + ); +} + +#[test] +fn usage_does_not_delay_content_or_enter_event_metadata() { + let mut state = state(); + state.sdk(notification(json!({"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":"Answer."}}}))).unwrap(); + assert_eq!( + state + .sdk(notification( + json!({"type":"stream_event","event":{"type":"content_block_stop","index":0}}) + )) + .unwrap(), + vec![Event::flush(0)] + ); + state.sdk(notification(json!({"type":"assistant","message":{"id":"msg-fixed","model":"claude-opus-5","content":[{"type":"text","text":"Answer."}],"usage":{"input_tokens":2,"output_tokens":3,"cache_creation_input_tokens":0,"cache_read_input_tokens":500}}}))).unwrap(); + state.sdk(notification(json!({"type":"result","subtype":"success","is_error":false,"modelUsage":{"claude-opus-5":{"inputTokens":2,"outputTokens":3,"cacheReadInputTokens":500}},"total_cost_usd":0.01}))).unwrap(); + let events = state.final_events.take().unwrap(); + assert_eq!(events, vec![Event::Finished(FinishReason::Completed)]); + assert_eq!( + state.usage_snapshot(), + json!({ + "native_session_id":"session-fixed", + "requests":{"msg-fixed":{"model":"claude-opus-5","input_tokens":2,"output_tokens":3,"cache_creation_input_tokens":0,"cache_read_input_tokens":500}}, + "runtime":{"model_usage":{"claude-opus-5":{"inputTokens":2,"outputTokens":3,"cacheReadInputTokens":500}},"estimated_cost_usd":0.01} + }) + ); +} + +#[test] +fn synthetic_error_reports_the_reason_not_a_model_mismatch() { + let mut state = state(); + let error = state.sdk(notification(json!({"type":"assistant","error":"rate_limit","message":{"model":"","content":[{"type":"text","text":"Subscription allowance exhausted."}]}}))).unwrap_err(); + assert_eq!( + error.message(), + "Claude Code rate_limit: Subscription allowance exhausted." + ); +} + +#[test] +fn runtime_can_resolve_a_model_alias() { + let mut state = state(); + state.model = "claude-haiku-4-5".parse().unwrap(); + assert_eq!(state.sdk(notification(json!({"type":"assistant","message":{"id":"msg-haiku","model":"claude-haiku-4-5-20251001","usage":{"input_tokens":1,"output_tokens":2}}}))).unwrap(), vec![]); + assert_eq!( + state.usage_snapshot()["requests"]["msg-haiku"]["model"], + "claude-haiku-4-5-20251001" + ); +} + +#[test] +fn unavailable_model_is_classified_with_the_requested_name() { + let mut state = state(); + let error = state.sdk(notification(json!({"type":"assistant","error":"model_not_found","message":{"content":[{"type":"text","text":"Not available on this account."}]}}))).unwrap_err(); + assert_eq!( + error.message(), + "Claude Code cannot use model `claude-opus-5`: Not available on this account." + ); + assert!(!error.is_retryable()); +} + +#[test] +fn sdk_failure_preserves_the_reported_details() { + let mut state = state(); + let error = state.sdk(notification(json!({"type":"result","subtype":"error_during_execution","is_error":true,"errors":["Adapter disconnected."]}))).unwrap_err(); + assert_eq!( + error.message(), + "Claude Code request failed (error_during_execution): Adapter disconnected." + ); +} + +#[test] +fn exhausted_runtime_output_limit_is_not_a_generic_or_retryable_error() { + let mut state = state(); + let error = state.sdk(notification(json!({"type":"assistant","error":"max_output_tokens","message":{"content":[{"type":"text","text":"Response exceeded 2048 output tokens."}]}}))).unwrap_err(); + assert_eq!(error.kind, StreamErrorKind::MaxOutputTokens); + assert!(!error.is_retryable()); +} + +#[test] +fn token_limit_is_not_reported_as_completion() { + let mut state = state(); + state.sdk(notification(json!({"type":"result","subtype":"success","is_error":false,"stop_reason":"max_tokens"}))).unwrap(); + assert_eq!(state.final_events.take().unwrap(), vec![Event::Finished( + FinishReason::MaxTokens + )]); +} + +#[test] +fn persisted_tool_output_is_reported_instead_of_silently_accepted() { + let mut state = state(); + let error = state.sdk(notification(json!({"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-fixed","content":"\nOutput too large. Full output saved to: /tmp/result.txt\n"}]}}))).unwrap_err(); + assert_eq!( + error.message(), + "Claude Code replaced tool result tool-fixed with a file reference; the ACP flow cannot \ + preserve this result inline" + ); +} + +#[test] +fn successful_subtype_does_not_hide_refusal() { + let mut state = state(); + state.sdk(notification(json!({"type":"result","subtype":"success","is_error":true,"stop_reason":"refusal","refusal":{"category":"test","explanation":"Refused fixture"}}))).unwrap(); + assert_eq!(state.final_events.take().unwrap(), vec![Event::Finished( + FinishReason::Refused { + category: Some("test".into()), + explanation: Some("Refused fixture".into()) + } + )]); +} diff --git a/crates/jp_llm/src/provider/anthropic/acp/recorded_tests.rs b/crates/jp_llm/src/provider/anthropic/acp/recorded_tests.rs new file mode 100644 index 000000000..db8061fad --- /dev/null +++ b/crates/jp_llm/src/provider/anthropic/acp/recorded_tests.rs @@ -0,0 +1,370 @@ +//! The subscription conversation, recorded once and replayed thereafter. +//! +//! Without `RECORD`, these replay `tests/fixtures/acp/live.jsonl`: no adapter +//! is spawned, no allowance is spent, and a missing recording fails rather than +//! passing quietly. +//! With `RECORD=1` they reach the installed runtime, measure what its caching +//! actually does, and write the recording back. +//! Same switch and same fixture root as the workspace's HTTP cassettes. +//! +//! The recording is the only non-circular evidence that [`super::schema`] +//! spells the protocol's field names correctly: those types are written by +//! hand, and a fixture written from the same reading of the spec would agree +//! with them whether or not the adapter does. +//! +//! What it does not cover is a tool host, so it carries no +//! `session/request_permission` and no tool-call session update — the two +//! messages whose fields are most hand-written. +//! Capturing those needs a run with tools available, which today means a real +//! `RECORD=1 jp query`. + +use std::{ + env, fmt, + sync::{Arc, Mutex}, + time::Duration, +}; + +use camino::Utf8PathBuf; +use datetime_literal::datetime; +use jp_config::{AppConfig, assistant::request::CachePolicy, model::parameters::ReasoningConfig}; +use jp_conversation::{ + ConversationStream, + event::{ChatRequest, ChatResponse, ConversationEvent}, + thread::ThreadBuilder, +}; +use serde_json::{Value, json}; +use tokio::sync::mpsc; +use tracing::{ + Event as TracingEvent, Subscriber, + field::{Field, Visit}, + instrument::WithSubscriber as _, +}; +use tracing_subscriber::{ + Layer, + layer::{Context, SubscriberExt as _}, + registry, +}; +use uuid::Uuid; + +use super::{ + cassette::{self, Framed, Recorded}, + inspect, model_details, options, + schema::agent_method, + transcript::PreparedRequest, + transport::{self, NativeArtifact, Transport}, +}; +use crate::{ + event::{Event, EventPart, FinishReason}, + query::{ChatQuery, QueryContext}, +}; + +/// Test-only observer of the production diagnostic event, never conversation +/// metadata. +#[derive(Clone, Default)] +pub(super) struct UsageCapture(Arc>>); + +impl UsageCapture { + pub(super) fn snapshot(&self) -> Option { + self.0.lock().unwrap().clone() + } +} + +struct UsageVisitor(Option); +impl Visit for UsageVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { + if field.name() == "usage" { + self.0 = Some( + serde_json::from_str(&format!("{value:?}")).expect("usage diagnostic must be JSON"), + ); + } + } +} + +impl Layer for UsageCapture { + fn on_event(&self, event: &TracingEvent<'_>, _: Context<'_, S>) { + if event.metadata().target() != "jp_llm::provider::anthropic::acp::transport" { + return; + } + let mut visitor = UsageVisitor(None); + event.record(&mut visitor); + if let Some(value) = visitor.0 { + *self.0.lock().unwrap() = Some(value); + } + } +} + +fn query(policy: CachePolicy, tag: &str) -> ChatQuery { + let mut config = AppConfig::new_test(); + config.assistant.model.id = "anthropic/claude-opus-5".parse().unwrap(); + config.assistant.model.parameters.max_tokens = Some(128); + config.assistant.model.parameters.reasoning = Some(ReasoningConfig::Off); + config.assistant.request.cache = policy; + let timestamp = datetime!(2026-09-11 12:00:00 Z); + let mut events = ConversationStream::new(config.into()).with_created_at(timestamp); + events.extend([ + ConversationEvent::new( + ChatRequest::from("The active invoice is INV-1042."), + timestamp, + ), + ConversationEvent::new(ChatResponse::message("Acknowledged."), timestamp), + ConversationEvent::new( + ChatRequest::from("Return only the active invoice ID."), + timestamp, + ), + ]); + // A stable prefix long enough for the model to cache it, and no longer: + // the recording holds one copy per connection, so every line here costs + // three in a committed file. Recording with 2000 lines produced 40920 + // cacheable tokens, far past any minimum. + // + // A prefix that stopped qualifying would fail the next recording rather + // than pass quietly, since the first conversation asserts the cache entry + // was created. + let reference = "Invoice INV-1042: amount 125, status paid.\n".repeat(400); + ThreadBuilder::new() + .with_system_prompt(format!( + "Qualification {tag}. Use the supplied invoice history.\n{reference}" + )) + .with_events(events) + .build() + .unwrap() + .into() +} + +fn context() -> QueryContext { + QueryContext { + root: Utf8PathBuf::from_path_buf(env::current_dir().unwrap()).unwrap(), + mcp_endpoint: None, + invocation: None, + } +} + +/// Run one conversation and return the usage the runtime reported. +/// +/// Everything above the transport is shared by both modes: the same request, +/// the same driver, the same assertions on what came back. +async fn conversation( + policy: CachePolicy, + tag: &str, + prepare: impl FnOnce(&PreparedRequest) -> (NativeArtifact, Box), +) -> Value { + let model = model_details(&"claude-opus-5".parse().unwrap()); + let prepared = PreparedRequest::new(&model, query(policy, tag)).unwrap(); + let (artifact, transport) = prepare(&prepared); + let environment = options::environment(&prepared, policy); + + let capture = UsageCapture::default(); + let subscriber = registry().with(capture.clone()); + let (sender, mut receiver) = mpsc::channel(64); + let driver = transport::drive( + prepared, + context(), + vec![], + environment, + artifact, + transport, + sender, + ) + .with_subscriber(subscriber); + + let collect = async { + let mut finish = None; + let mut response = String::new(); + while let Some(event) = receiver.recv().await { + match event.unwrap() { + Event::Part { + part: EventPart::Message(text), + .. + } => response.push_str(&text), + Event::Finished(reason) => finish = Some(reason), + _ => {} + } + } + (finish, response) + }; + + let (result, (finish, response)) = tokio::time::timeout(Duration::from_mins(2), async { + tokio::join!(driver, collect) + }) + .await + .expect("the ACP conversation exceeded two minutes"); + + result.unwrap(); + assert_eq!(finish, Some(FinishReason::Completed)); + assert_eq!(response.trim(), "INV-1042"); + capture.snapshot().expect("runtime did not report usage") +} + +/// Reach the installed adapter, writing the transcript it resumes from and +/// recording every message it exchanges. +fn live( + policy: CachePolicy, +) -> impl FnOnce(&PreparedRequest) -> (NativeArtifact, Box) { + move |prepared| { + let launch = transport::launch(prepared, &context(), policy).expect("a prepared launch"); + ( + launch.artifact, + Box::new(transport::Spawned { + command: launch.command, + tap: cassette::tap("live"), + }), + ) + } +} + +/// The session the recording resumed, when it resumed one. +/// +/// Read back out of the `session/load` JP sent, which is the only place the id +/// appears before the agent starts answering. +fn recorded_session(script: &[Framed]) -> Option { + script + .iter() + .find(|entry| entry.message["method"] == json!(agent_method::SESSION_LOAD)) + .and_then(|entry| entry.message["params"]["sessionId"].as_str()) + .and_then(|id| id.parse().ok()) +} + +/// Answer from one recorded connection, touching neither the network nor the +/// Claude directory. +/// +/// The transcript is not written, because nothing here reads one. +/// The session id is the recording's own: JP drops every SDK notification whose +/// session is not the one it opened, so a replay that minted a fresh id would +/// decode an empty conversation and report the result as missing. +/// +/// A recording with no `session/load` had no history to resume, and leaving the +/// id unset is what makes the replay open with `session/new` as it did. +fn replayed( + script: Vec, +) -> impl FnOnce(&PreparedRequest) -> (NativeArtifact, Box) { + move |_| { + ( + NativeArtifact { + session: recorded_session(&script), + path: None, + }, + Box::new(Recorded(script)), + ) + } +} + +/// The usage without its session id, which a live run mints per connection and +/// a replay adopts from the recording, so it differs between them without +/// saying anything about either. +fn anonymous(usage: &Value) -> Value { + let mut usage = usage.clone(); + if let Some(object) = usage.as_object_mut() { + object.insert("native_session_id".into(), json!("[session]")); + } + usage +} + +/// What keeps one run's cache measurements out of the next run's way. +/// +/// A recording needs a tag nothing has seen, so its first conversation meets a +/// cold cache and the entry it creates is its own; all three share it, so the +/// second meets what the first left. +/// Generated rather than asked for, since "fresh every time" is a property a +/// machine keeps and a person forgets. +/// +/// A replay measures no cache, so it takes a fixed tag and stays reproducible. +fn tag(recording: bool) -> String { + if recording { + Uuid::new_v4().to_string() + } else { + "replay".to_owned() + } +} + +/// One conversation, answered from `script` when there is one and by the +/// runtime when there is not. +async fn attempt(policy: CachePolicy, tag: &str, script: Option>) -> Value { + match script { + Some(script) => conversation(policy, tag, replayed(script)).await, + None => conversation(policy, tag, live(policy)).await, + } +} + +fn tokens(snapshot: &Value, key: &str) -> u64 { + let requests = snapshot["requests"] + .as_object() + .expect("missing main request usage"); + assert!(!requests.is_empty(), "runtime omitted main request usage"); + requests + .values() + .map(|usage| { + usage[key] + .as_u64() + .expect("runtime omitted a token counter") + }) + .sum() +} + +/// Three conversations: a cached one, the same one again, and one with caching +/// off. +/// +/// Replayed by default, so this costs nothing and proves the recorded traffic +/// still decodes into the events and usage JP reports. +/// Under `RECORD=1` it reaches the runtime instead, and adds the measurements +/// only a live service can answer — that an entry was created, that a second +/// session reconstructed it, and that turning caching off stops both. +/// Those cannot be replayed: a fixture compared against itself passes whatever +/// it contains. +#[tokio::test] +async fn cache_reconstruction() { + let recording = cassette::recording(); + if recording { + // Reaching the runtime with a stale adapter or a lapsed login produces + // a failure that reads as a protocol problem, so check first. + inspect() + .await + .expect("a qualified adapter and an active login"); + } + let tag = tag(recording); + + let mut scripts = if recording { + Vec::new() + } else { + let read = cassette::read("live").unwrap_or_else(|error| panic!("{error}")); + let scripts = cassette::connections(read); + assert_eq!( + scripts.len(), + 3, + "the recording should hold one connection per request" + ); + scripts + } + .into_iter(); + + let first = attempt(CachePolicy::Short, &tag, scripts.next()).await; + let repeat = attempt(CachePolicy::Short, &tag, scripts.next()).await; + let off = attempt(CachePolicy::Off, &tag, scripts.next()).await; + + insta::assert_json_snapshot!( + "cache_reconstruction_usage", + json!({ + "initial": anonymous(&first), + "reconstructed": anonymous(&repeat), + "off": anonymous(&off), + }) + ); + + if !recording { + return; + } + + println!("{}", json!({"case": "initial", "usage": first})); + println!("{}", json!({"case": "reconstructed", "usage": repeat})); + println!("{}", json!({"case": "off", "usage": off})); + assert!( + tokens(&first, "cache_creation_input_tokens") > 0, + "runtime-managed caching did not create an entry" + ); + assert_ne!(first["native_session_id"], repeat["native_session_id"]); + assert!( + tokens(&repeat, "cache_read_input_tokens") > 0, + "reconstruction did not reuse any cache" + ); + assert_eq!(tokens(&off, "cache_read_input_tokens"), 0); + assert_eq!(tokens(&off, "cache_creation_input_tokens"), 0); +} diff --git a/crates/jp_llm/src/provider/anthropic/acp/rpc.rs b/crates/jp_llm/src/provider/anthropic/acp/rpc.rs new file mode 100644 index 000000000..db18ba211 --- /dev/null +++ b/crates/jp_llm/src/provider/anthropic/acp/rpc.rs @@ -0,0 +1,361 @@ +//! JSON-RPC 2.0 over an agent's stdio, in the shape ACP uses it. +//! +//! [`drive`] owns one connection: it writes outgoing messages to the agent's +//! stdin, reads newline-delimited messages from its stdout, and runs a +//! foreground task that issues requests through a [`Peer`]. +//! +//! Traffic in the other direction — the agent's notifications and its requests +//! to us — reaches the [`Handler`] this module is given. +//! A handler that fails ends the connection, which is how a rejected +//! notification aborts a request already in flight. +//! +//! The wire types live in `agent_client_protocol_schema`; this module only +//! frames them. +//! Method names come from that crate's `AGENT_METHOD_NAMES` and +//! `CLIENT_METHOD_NAMES` rather than string literals here, so a protocol rename +//! is a compile error instead of a silent no-op. + +use std::{ + collections::HashMap, + fmt, + sync::{ + Arc, Mutex, PoisonError, + atomic::{AtomicI64, Ordering}, + }, +}; + +use futures::future::BoxFuture; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use serde_json::{Value, json}; +use tokio::{ + io::{AsyncBufReadExt as _, AsyncRead, AsyncWrite, AsyncWriteExt as _, BufReader}, + sync::{mpsc, oneshot}, +}; +use tracing::debug; + +/// A request JP sends to the agent. +/// +/// The response type is part of the contract, so a caller cannot pair a request +/// with the wrong reply shape. +pub(super) trait Request: Serialize { + /// The JSON-RPC method, taken from the schema crate's method-name table. + const METHOD: &'static str; + + /// What the agent answers with. + type Response: DeserializeOwned; +} + +/// A JSON-RPC error, as both a received failure and one JP reports. +/// +/// `data` carries whatever diagnostic context the sender attached; JP uses it +/// for the agent's exit status and its tail of stderr. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct RpcError { + pub code: i64, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +impl RpcError { + /// The JSON-RPC code for a failure that is not the caller's fault. + const INTERNAL: i64 = -32603; + + /// A rejection of the caller's arguments. + /// + /// JP only ever receives one of these; it is constructed here so a scripted + /// adapter can send one. + #[cfg(test)] + pub(super) fn invalid_params() -> Self { + Self { + code: -32602, + message: "Invalid params".into(), + data: None, + } + } + + /// An internal error with no message beyond its code. + pub(super) fn internal_error() -> Self { + Self { + code: Self::INTERNAL, + message: "Internal error".into(), + data: None, + } + } + + /// Report an arbitrary failure as an internal error, keeping its display + /// text as the message. + pub(super) fn into_internal_error(error: impl fmt::Display) -> Self { + Self { + code: Self::INTERNAL, + message: error.to_string(), + data: None, + } + } + + /// Attach diagnostic context, replacing whatever was there. + #[must_use] + pub(super) fn data(mut self, data: impl Into) -> Self { + self.data = Some(data.into()); + self + } +} + +impl fmt::Display for RpcError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message)?; + if let Some(data) = &self.data { + write!(f, " ({data})")?; + } + Ok(()) + } +} + +impl std::error::Error for RpcError {} + +/// Something the agent sent that was not an answer to one of our requests. +pub(super) enum Inbound { + /// A one-way message. + /// Returning an error from its handler ends the connection. + Notification { method: String, params: Value }, + + /// A request awaiting our answer, which is the handler's returned value. + Request { method: String, params: Value }, +} + +/// Handles everything the agent initiates. +/// +/// Boxed rather than generic because one connection has exactly one handler and +/// it captures the whole translation state. +pub(super) type Handler = + Box BoxFuture<'static, Result> + Send + Sync>; + +/// Issues requests on an open connection. +/// +/// Cloneable, so the foreground task and any handler can both use it. +#[derive(Clone)] +pub(super) struct Peer { + outgoing: mpsc::UnboundedSender, + pending: Pending, + next_id: Arc, +} + +type Pending = Arc>>>>; + +fn lock(value: &Mutex) -> std::sync::MutexGuard<'_, T> { + value.lock().unwrap_or_else(PoisonError::into_inner) +} + +impl Peer { + /// Send a request and wait for its answer. + /// + /// Returns the agent's error when it rejects the request, and an internal + /// error when the connection ends before the answer arrives. + pub(super) async fn request(&self, params: R) -> Result { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let params = serde_json::to_value(params).map_err(RpcError::into_internal_error)?; + let (sender, receiver) = oneshot::channel(); + lock(&self.pending).insert(id, sender); + let message = json!({"jsonrpc": "2.0", "id": id, "method": R::METHOD, "params": params}); + if self.outgoing.send(message).is_err() { + lock(&self.pending).remove(&id); + return Err(RpcError::internal_error().data("ACP connection closed")); + } + let result = receiver + .await + .map_err(|_| RpcError::internal_error().data("ACP connection closed"))??; + serde_json::from_value(result).map_err(RpcError::into_internal_error) + } +} + +/// Which end of a connection sent a message. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(super) enum Side { + /// JP, acting as the ACP client. + Jp, + + /// The adapter JP is connected to. + Agent, +} + +/// Observes every framed message crossing a connection, in both directions. +/// +/// The observer runs inline on the reader and writer tasks, so it must not +/// block: one that falls behind stalls the connection rather than the observer. +/// +/// Where the messages go is [`super::cassette`]'s decision; this type is only +/// the place they pass through. +#[derive(Clone)] +pub(super) struct Tap(Option>); + +type Observer = dyn Fn(Side, &Value) + Send + Sync; + +impl Tap { + /// Observe nothing, at the cost of one null check per message. + pub(super) fn none() -> Self { + Self(None) + } + + /// Observe every message with `observe`. + pub(super) fn new(observe: impl Fn(Side, &Value) + Send + Sync + 'static) -> Self { + Self(Some(Arc::new(observe))) + } + + /// Hand one message to the observer, if there is one. + pub(super) fn observe(&self, side: Side, message: &Value) { + if let Some(observer) = &self.0 { + observer(side, message); + } + } +} + +/// Run one connection until the foreground task finishes or the agent stops. +/// +/// Returns the foreground task's result, unless the connection failed first: a +/// handler error, unreadable input, or the agent closing its output while a +/// request is still outstanding all end the connection with that failure. +pub(super) async fn drive( + input: I, + output: O, + tap: Tap, + handler: Handler, + foreground: F, +) -> Result<(), RpcError> +where + I: AsyncWrite + Unpin + Send + 'static, + O: AsyncRead + Unpin + Send + 'static, + F: FnOnce(Peer) -> Fut, + Fut: Future>, +{ + let (outgoing, mut queued) = mpsc::unbounded_channel(); + let pending: Pending = Pending::default(); + let peer = Peer { + outgoing: outgoing.clone(), + pending: pending.clone(), + next_id: Arc::new(AtomicI64::new(1)), + }; + + let writer_tap = tap.clone(); + let mut writer = tokio::spawn(async move { + let mut input = input; + while let Some(message) = queued.recv().await { + writer_tap.observe(Side::Jp, &message); + let mut line = serde_json::to_vec(&message).map_err(RpcError::into_internal_error)?; + line.push(b'\n'); + input + .write_all(&line) + .await + .map_err(RpcError::into_internal_error)?; + input.flush().await.map_err(RpcError::into_internal_error)?; + } + Ok::<(), RpcError>(()) + }); + + let reader_outgoing = outgoing.clone(); + let mut reader = tokio::spawn(async move { + let mut lines = BufReader::new(output).lines(); + while let Some(line) = lines + .next_line() + .await + .map_err(RpcError::into_internal_error)? + { + if line.trim().is_empty() { + continue; + } + let message: Value = match serde_json::from_str(&line) { + Ok(message) => message, + // A line that is not JSON at all is the agent talking past the + // protocol; the connection survives it, since the request we + // are waiting on may still be answered. + Err(error) => { + debug!(%error, "Skipping unparseable ACP line"); + continue; + } + }; + tap.observe(Side::Agent, &message); + dispatch(message, &pending, &handler, &reader_outgoing).await?; + } + // The agent closed its output. Anything still waiting will never be + // answered, so fail it rather than hang. + for (_, sender) in lock(&pending).drain() { + drop(sender.send(Err( + RpcError::internal_error().data("ACP connection closed"), + ))); + } + Ok::<(), RpcError>(()) + }); + + let outcome = tokio::select! { + biased; + reader = &mut reader => join(reader), + writer = &mut writer => join(writer), + result = foreground(peer) => result, + }; + // Both tasks outlive the connection otherwise: the reader parks on the + // agent's output and holds the handler, and through it whatever the handler + // captured. A caller waiting for its own channel to close would wait on a + // sender kept alive by a connection that has already finished. + // + // Anything still queued for the agent is dropped with the writer, which is + // what a finished connection wants: the sequence is over either way. + reader.abort(); + writer.abort(); + outcome +} + +fn join(result: Result, tokio::task::JoinError>) -> Result<(), RpcError> { + result.map_err(RpcError::into_internal_error)? +} + +/// Route one parsed message: an answer, a notification, or a request. +async fn dispatch( + message: Value, + pending: &Pending, + handler: &Handler, + outgoing: &mpsc::UnboundedSender, +) -> Result<(), RpcError> { + let id = message.get("id").and_then(Value::as_i64); + let method = message.get("method").and_then(Value::as_str); + + let Some(method) = method else { + // No method means this answers one of our requests. + let Some(id) = id else { + debug!("Skipping ACP message with neither method nor id"); + return Ok(()); + }; + let Some(sender) = lock(pending).remove(&id) else { + debug!(id, "Skipping ACP answer to an unknown request"); + return Ok(()); + }; + let answer = match message.get("error") { + Some(error) => Err(serde_json::from_value(error.clone()) + .unwrap_or_else(|_| RpcError::internal_error().data(error.clone()))), + None => Ok(message.get("result").cloned().unwrap_or(Value::Null)), + }; + drop(sender.send(answer)); + return Ok(()); + }; + + let params = message.get("params").cloned().unwrap_or(Value::Null); + let method = method.to_owned(); + + let Some(id) = id else { + // A handler that refuses a notification ends the connection: the + // authentication update uses this to stop a prompt already in flight. + handler(Inbound::Notification { method, params }).await?; + return Ok(()); + }; + + let answer = handler(Inbound::Request { method, params }).await; + let reply = match answer { + Ok(result) => json!({"jsonrpc": "2.0", "id": id, "result": result}), + Err(error) => json!({"jsonrpc": "2.0", "id": id, "error": error}), + }; + drop(outgoing.send(reply)); + Ok(()) +} + +#[cfg(test)] +#[path = "rpc_tests.rs"] +mod tests; diff --git a/crates/jp_llm/src/provider/anthropic/acp/rpc_tests.rs b/crates/jp_llm/src/provider/anthropic/acp/rpc_tests.rs new file mode 100644 index 000000000..656d0587f --- /dev/null +++ b/crates/jp_llm/src/provider/anthropic/acp/rpc_tests.rs @@ -0,0 +1,446 @@ +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use serde::Deserialize; +use serde_json::json; +use tokio::{ + io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader, DuplexStream}, + sync::mpsc, + time::{Duration, timeout}, +}; + +use super::*; + +/// A request with a fixed method and an echoing response shape. +#[derive(Serialize)] +struct Echo { + value: String, +} + +#[derive(Debug, Deserialize, PartialEq)] +struct Echoed { + echoed: String, +} + +impl Request for Echo { + const METHOD: &'static str = "test/echo"; + + type Response = Echoed; +} + +/// One end of an in-memory connection, read and written a line at a time. +/// +/// Stands in for the adapter: the test writes what the agent would send and +/// reads what JP sends, so the framing is exercised rather than bypassed. +struct Agent { + lines: tokio::io::Lines>, + writer: DuplexStream, +} + +impl Agent { + async fn read(&mut self) -> Value { + let line = timeout(Duration::from_secs(5), self.lines.next_line()) + .await + .expect("agent read timed out") + .unwrap() + .expect("JP closed its output"); + serde_json::from_str(&line).unwrap() + } + + async fn write(&mut self, message: Value) { + let mut line = serde_json::to_vec(&message).unwrap(); + line.push(b'\n'); + self.writer.write_all(&line).await.unwrap(); + self.writer.flush().await.unwrap(); + } +} + +/// Wire an agent to `drive`, returning the agent end and the driver's handle. +fn connect( + handler: Handler, + foreground: impl FnOnce(Peer) -> BoxFuture<'static, Result<(), RpcError>> + Send + 'static, +) -> (Agent, tokio::task::JoinHandle>) { + // JP's stdin is what the agent writes to; JP's stdout is what it reads. + let (jp_input, agent_writer) = tokio::io::duplex(8192); + let (jp_output, agent_reader) = tokio::io::duplex(8192); + let driver = tokio::spawn(drive( + agent_reader, + jp_input, + Tap::none(), + handler, + foreground, + )); + ( + Agent { + lines: BufReader::new(jp_output).lines(), + writer: agent_writer, + }, + driver, + ) +} + +/// A handler that answers nothing, for tests that only send requests. +fn silent() -> Handler { + Box::new(|_| Box::pin(async { Ok(Value::Null) })) +} + +fn boxed(foreground: F) -> impl FnOnce(Peer) -> BoxFuture<'static, Result<(), RpcError>> +where + F: FnOnce(Peer) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, +{ + move |peer| Box::pin(foreground(peer)) +} + +#[tokio::test] +async fn a_request_carries_its_method_and_receives_its_typed_answer() { + let (mut agent, driver) = connect( + silent(), + boxed(|peer: Peer| async move { + let answer = peer + .request(Echo { + value: "hello".into(), + }) + .await?; + assert_eq!(answer, Echoed { + echoed: "hello".into() + }); + Ok(()) + }), + ); + let request = agent.read().await; + assert_eq!( + request, + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "test/echo", + "params": {"value": "hello"}, + }) + ); + agent + .write(json!({"jsonrpc": "2.0", "id": 1, "result": {"echoed": "hello"}})) + .await; + driver.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn answers_reach_the_request_that_asked_even_when_they_arrive_reversed() { + let (mut agent, driver) = connect( + silent(), + boxed(|peer: Peer| async move { + let second = peer.clone(); + // Both are outstanding before either is answered, so the ids are + // the only thing that can pair them up. + let first = tokio::spawn(async move { + peer.request(Echo { + value: "first".into(), + }) + .await + }); + let second = tokio::spawn(async move { + second + .request(Echo { + value: "second".into(), + }) + .await + }); + assert_eq!(first.await.unwrap().unwrap(), Echoed { + echoed: "one".into() + }); + assert_eq!(second.await.unwrap().unwrap(), Echoed { + echoed: "two".into() + }); + Ok(()) + }), + ); + let mut ids = vec![]; + for _ in 0..2 { + let request = agent.read().await; + ids.push(( + request["id"].as_i64().unwrap(), + request["params"]["value"].as_str().unwrap().to_owned(), + )); + } + ids.sort_by(|a, b| a.1.cmp(&b.1)); + let [(first, _), (second, _)] = ids.as_slice() else { + panic!("expected two requests") + }; + // Answer the second one first. + agent + .write(json!({"jsonrpc": "2.0", "id": second, "result": {"echoed": "two"}})) + .await; + agent + .write(json!({"jsonrpc": "2.0", "id": first, "result": {"echoed": "one"}})) + .await; + driver.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn a_rejected_request_returns_the_agents_error_rather_than_a_local_one() { + let (mut agent, driver) = connect( + silent(), + boxed(|peer: Peer| async move { + let error = peer + .request(Echo { + value: "nope".into(), + }) + .await + .unwrap_err(); + assert_eq!(error.code, -32602); + assert_eq!(error.message, "Unknown model on this account."); + assert_eq!(error.data, Some(json!({"model": "future"}))); + Ok(()) + }), + ); + let id = agent.read().await["id"].clone(); + agent + .write(json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -32602, + "message": "Unknown model on this account.", + "data": {"model": "future"}, + }, + })) + .await; + driver.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn an_agent_request_is_answered_with_the_handlers_value() { + let (mut agent, driver) = connect( + Box::new(|message| { + Box::pin(async move { + let Inbound::Request { method, params } = message else { + panic!("expected a request") + }; + assert_eq!(method, "session/request_permission"); + assert_eq!(params, json!({"toolCall": "read"})); + Ok(json!({"outcome": "allowed"})) + }) + }), + // Held open until the agent's request has been answered. + boxed(|peer: Peer| async move { + peer.request(Echo { + value: "wait".into(), + }) + .await?; + Ok(()) + }), + ); + let pending = agent.read().await["id"].clone(); + agent + .write(json!({ + "jsonrpc": "2.0", + "id": 77, + "method": "session/request_permission", + "params": {"toolCall": "read"}, + })) + .await; + assert_eq!( + agent.read().await, + json!({ + "jsonrpc": "2.0", + "id": 77, + "result": {"outcome": "allowed"}, + }) + ); + agent + .write(json!({"jsonrpc": "2.0", "id": pending, "result": {"echoed": "wait"}})) + .await; + driver.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn a_handler_refusing_a_notification_ends_the_connection() { + let (mut agent, driver) = connect( + Box::new(|message| { + Box::pin(async move { + assert!(matches!(message, Inbound::Notification { .. })); + Err(RpcError::into_internal_error("subscription required")) + }) + }), + // Never answered: the notification has to be what ends this. + boxed(|peer: Peer| async move { + peer.request(Echo { + value: "streaming".into(), + }) + .await?; + Ok(()) + }), + ); + agent.read().await; + agent + .write(json!({"jsonrpc": "2.0", "method": "_auth/status_update", "params": {}})) + .await; + let error = timeout(Duration::from_secs(5), driver) + .await + .expect("connection should end") + .unwrap() + .unwrap_err(); + assert_eq!(error.message, "subscription required"); +} + +#[tokio::test] +async fn a_request_outstanding_when_the_agent_goes_away_fails_rather_than_hangs() { + let (agent, driver) = connect( + silent(), + boxed(|peer: Peer| async move { + let error = peer + .request(Echo { + value: "orphan".into(), + }) + .await + .unwrap_err(); + assert_eq!(error.data, Some(json!("ACP connection closed"))); + Ok(()) + }), + ); + drop(agent); + timeout(Duration::from_secs(5), driver) + .await + .expect("connection should end") + .unwrap() + .unwrap(); +} + +#[tokio::test] +async fn nothing_the_handler_captured_outlives_the_connection() { + // The handler holds a sender; a caller watching that channel for the end of + // the turn only sees it close once the connection has let the handler go. + let (events, mut watching) = mpsc::channel::<()>(1); + let (mut agent, driver) = connect( + Box::new(move |_| { + let _events = events.clone(); + Box::pin(async { Ok(Value::Null) }) + }), + boxed(|peer: Peer| async move { + peer.request(Echo { + value: "done".into(), + }) + .await?; + Ok(()) + }), + ); + let id = agent.read().await["id"].clone(); + agent + .write(json!({"jsonrpc": "2.0", "id": id, "result": {"echoed": "done"}})) + .await; + driver.await.unwrap().unwrap(); + // The agent is still connected, so only releasing the handler can close it. + assert!( + timeout(Duration::from_secs(5), watching.recv()) + .await + .expect("the event channel outlived the connection") + .is_none() + ); +} + +#[tokio::test] +async fn a_tap_observes_both_directions_verbatim() { + // What the adapter sends is what a fixture must replay, so the tap sees + // each message exactly as framed rather than as JP decoded it. + let observed = Arc::new(std::sync::Mutex::new(Vec::new())); + let collected = observed.clone(); + let tap = Tap::new(move |side, message| { + collected + .lock() + .unwrap_or_else(PoisonError::into_inner) + .push((side, message.clone())); + }); + let (jp_input, agent_writer) = tokio::io::duplex(8192); + let (jp_output, agent_reader) = tokio::io::duplex(8192); + let driver = tokio::spawn(drive( + agent_reader, + jp_input, + tap, + silent(), + boxed(|peer: Peer| async move { + peer.request(Echo { + value: "out".into(), + }) + .await?; + Ok(()) + }), + )); + let mut agent = Agent { + lines: BufReader::new(jp_output).lines(), + writer: agent_writer, + }; + let id = agent.read().await["id"].clone(); + agent + .write(json!({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "s"}})) + .await; + agent + .write(json!({"jsonrpc": "2.0", "id": id, "result": {"echoed": "out"}})) + .await; + driver.await.unwrap().unwrap(); + + let observed = observed + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone(); + assert_eq!(observed, [ + ( + Side::Jp, + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "test/echo", + "params": {"value": "out"}, + }) + ), + ( + Side::Agent, + json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": {"sessionId": "s"}, + }) + ), + ( + Side::Agent, + json!({"jsonrpc": "2.0", "id": 1, "result": {"echoed": "out"}}) + ), + ]); +} + +#[tokio::test] +async fn a_line_that_is_not_json_does_not_end_the_connection() { + let handled = Arc::new(AtomicUsize::new(0)); + let counted = handled.clone(); + let (mut agent, driver) = connect( + Box::new(move |_| { + let counted = counted.clone(); + Box::pin(async move { + counted.fetch_add(1, Ordering::SeqCst); + Ok(Value::Null) + }) + }), + boxed(|peer: Peer| async move { + peer.request(Echo { + value: "after".into(), + }) + .await?; + Ok(()) + }), + ); + let id = agent.read().await["id"].clone(); + agent + .writer + .write_all(b"npm warn: not json\n") + .await + .unwrap(); + agent + .write(json!({"jsonrpc": "2.0", "method": "session/update", "params": {}})) + .await; + agent + .write(json!({"jsonrpc": "2.0", "id": id, "result": {"echoed": "after"}})) + .await; + driver.await.unwrap().unwrap(); + assert_eq!(handled.load(Ordering::SeqCst), 1); +} diff --git a/crates/jp_llm/src/provider/anthropic/acp/schema.rs b/crates/jp_llm/src/provider/anthropic/acp/schema.rs new file mode 100644 index 000000000..33880591d --- /dev/null +++ b/crates/jp_llm/src/provider/anthropic/acp/schema.rs @@ -0,0 +1,415 @@ +//! The ACP v1 wire types JP exchanges with the Claude Code adapter. +//! +//! This is the slice of the protocol JP actually reads or writes, not the whole +//! of it. +//! Serde ignores fields that are not declared, so a message carrying more than +//! this decodes fine and the extra fields are dropped; that is what keeps this +//! file proportional to JP's use rather than to the spec. +//! +//! Field names are the protocol's, spelled here rather than derived from the +//! Rust names: every struct carries `rename_all = "camelCase"` and `_meta` is +//! renamed by hand. +//! A mistake in one of them is a field that silently decodes as absent, which +//! is why `live_tests` records real adapter traffic and the ordinary suite +//! replays it. +//! +//! Optional fields are `#[serde(default)]` throughout. +//! The adapter omits anything it has no value for, and an absent field is never +//! an error. + +use std::fmt; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +/// Opaque protocol metadata, carried but not interpreted. +pub(super) type Meta = Map; + +/// Declare one of the protocol's string-newtype identifiers. +/// +/// Each is `serde(transparent)`, so it is a bare JSON string on the wire. +macro_rules! identifier { + ($(#[$doc:meta])* $name:ident) => { + $(#[$doc])* + /// Public because [`super::Error`] names one; the module itself is + /// private, so this does not widen the crate's surface. + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] + #[serde(transparent)] + pub struct $name(pub String); + + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } + } + + impl From for $name { + fn from(value: String) -> Self { + Self(value) + } + } + + impl From<&str> for $name { + fn from(value: &str) -> Self { + Self(value.to_owned()) + } + } + }; +} + +identifier!( + /// Identifies one conversation with the adapter. + SessionId +); +identifier!( + /// Identifies one tool call within a session. + ToolCallId +); +identifier!( + /// Names a session setting, such as `model` or `mode`. + SessionConfigId +); +identifier!( + /// One selectable value of a session setting. + SessionConfigValueId +); +identifier!( + /// Identifies one choice offered in a permission request. + PermissionOptionId +); + +/// The protocol revision, a bare integer on the wire. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub(super) struct ProtocolVersion(pub u16); + +impl ProtocolVersion { + /// The only revision JP speaks. + pub(super) const V1: Self = Self(1); +} + +/// Methods the agent handles, which JP calls. +pub(super) mod agent_method { + pub(in super::super) const INITIALIZE: &str = "initialize"; + pub(in super::super) const SESSION_NEW: &str = "session/new"; + pub(in super::super) const SESSION_LOAD: &str = "session/load"; + pub(in super::super) const SESSION_SET_CONFIG_OPTION: &str = "session/set_config_option"; + pub(in super::super) const SESSION_PROMPT: &str = "session/prompt"; +} + +/// Methods the client handles, which the agent calls on JP. +pub(super) mod client_method { + pub(in super::super) const SESSION_UPDATE: &str = "session/update"; + pub(in super::super) const SESSION_REQUEST_PERMISSION: &str = "session/request_permission"; +} + +// Initialization + +/// Opens the connection and settles what each side supports. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct InitializeRequest { + pub protocol_version: ProtocolVersion, + + /// What JP can do on the agent's behalf. + /// + /// JP implements none of the optional client methods, so every capability + /// here is false. + /// It is sent rather than omitted so the agent never has to infer the + /// answer from a missing field. + pub client_capabilities: ClientCapabilities, +} + +/// The optional client methods JP does not implement. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct ClientCapabilities { + pub fs: FileSystemCapabilities, + + /// Whether JP serves the `terminal/*` methods. + pub terminal: bool, +} + +/// The `fs/*` methods JP does not serve. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct FileSystemCapabilities { + pub read_text_file: bool, + pub write_text_file: bool, +} + +/// What the agent settled on, and what it can do. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct InitializeResponse { + pub protocol_version: ProtocolVersion, + + #[serde(default)] + pub agent_capabilities: AgentCapabilities, +} + +/// The optional agent methods JP checks for before relying on them. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct AgentCapabilities { + /// Whether `session/load` is available, which JP needs to supply history. + #[serde(default)] + pub load_session: bool, + + #[serde(default)] + pub mcp_capabilities: McpCapabilities, +} + +/// The MCP transports the agent can connect out over. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct McpCapabilities { + /// Whether the agent can reach JP's loopback MCP endpoint. + #[serde(default)] + pub http: bool, +} + +// Session setup + +/// Starts a session with no prior history. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct NewSessionRequest { + pub cwd: String, + + /// MCP servers the agent should connect to, as protocol objects. + /// + /// Left untyped because JP only ever sends one shape, the HTTP entry naming + /// its own endpoint. + pub mcp_servers: Vec, + + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// The session the agent created. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct NewSessionResponse { + pub session_id: SessionId, +} + +/// Resumes a session whose transcript JP has already written to disk. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct LoadSessionRequest { + pub session_id: SessionId, + pub cwd: String, + pub mcp_servers: Vec, + + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// Acknowledges the load. +/// Everything it carries is optional and unread. +#[derive(Debug, Clone, Default, Deserialize)] +pub(super) struct LoadSessionResponse {} + +// Session configuration + +/// Selects a value for one session setting. +/// +/// Only the id-valued form is sent: JP sets `model` and `mode`, both of which +/// the adapter models as selects. +/// A boolean setting would carry an extra `type` discriminator this does not +/// emit. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct SetSessionConfigOptionRequest { + pub session_id: SessionId, + pub config_id: SessionConfigId, + pub value: SessionConfigValueId, +} + +/// Every setting and its value after the change. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct SetSessionConfigOptionResponse { + #[serde(default)] + pub config_options: Vec, +} + +/// One setting, with the type-specific part flattened alongside it. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct SessionConfigOption { + pub id: SessionConfigId, + + #[serde(flatten)] + pub kind: SessionConfigKind, +} + +/// A setting's shape and current value, discriminated by `type`. +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(super) enum SessionConfigKind { + /// A choice from a list, which is how the adapter models `model` and + /// `mode`. + #[serde(rename_all = "camelCase")] + Select { current_value: SessionConfigValueId }, + + /// Any other shape. + /// JP sets no such setting and reads none. + #[serde(other)] + Other, +} + +// Prompting + +/// Submits the user's turn and blocks until the agent finishes it. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct PromptRequest { + pub session_id: SessionId, + + /// The user's message as protocol content blocks. + /// + /// Left untyped because JP only ever sends one text block; everything else + /// reaches the adapter through the transcript it loads. + pub prompt: Vec, +} + +/// Ends the turn. +/// Its stop reason is unread: JP takes the outcome from the SDK's own final +/// message instead, which carries the usage and refusal detail this does not. +#[derive(Debug, Clone, Default, Deserialize)] +pub(super) struct PromptResponse {} + +// Session updates + +/// One update about a session's progress. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct SessionNotification { + pub session_id: SessionId, + pub update: SessionUpdate, +} + +/// What the update is about, discriminated by `sessionUpdate`. +/// +/// JP reads the two tool-call variants and ignores the rest: message and +/// thought chunks arrive again through the SDK's own notifications, which carry +/// the token usage JP needs. +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "sessionUpdate", rename_all = "snake_case")] +pub(super) enum SessionUpdate { + /// A tool call has started. + ToolCall(ToolCall), + + /// A tool call's status or output changed. + ToolCallUpdate(ToolCallUpdate), + + /// Anything else the agent reports. + #[serde(other)] + Other, +} + +/// A tool call the agent has begun. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct ToolCall { + pub tool_call_id: ToolCallId, + + #[serde(rename = "_meta", default)] + pub meta: Option, +} + +/// A change to a tool call already in flight. +/// +/// The protocol nests the mutable fields under a flattened object, so they +/// appear beside `toolCallId` on the wire and are declared flat here. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct ToolCallUpdate { + pub tool_call_id: ToolCallId, + + #[serde(default)] + pub status: Option, + + /// The arguments the model produced, as the tool's own JSON object. + #[serde(default)] + pub raw_input: Option, + + #[serde(rename = "_meta", default)] + pub meta: Option, +} + +/// How far along a tool call is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum ToolCallStatus { + Pending, + InProgress, + Completed, + Failed, + + /// A status this build does not know. + /// Treated as still running. + #[serde(other)] + Other, +} + +// Permission + +/// Asks JP to authorize one tool call before the agent runs it. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct RequestPermissionRequest { + pub session_id: SessionId, + + /// The call awaiting authorization, in the same shape as an update. + pub tool_call: ToolCallUpdate, + + pub options: Vec, +} + +/// One answer the agent will accept. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct PermissionOption { + pub option_id: PermissionOptionId, + pub kind: PermissionOptionKind, +} + +/// What choosing an option means. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum PermissionOptionKind { + /// Authorize this call only. + /// The only kind JP selects: remembering a decision is the Host's job, not + /// the adapter's. + AllowOnce, + AllowAlways, + RejectOnce, + RejectAlways, + + /// A kind this build does not know. + #[serde(other)] + Other, +} + +/// JP's answer to a permission request. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct RequestPermissionResponse { + pub outcome: RequestPermissionOutcome, +} + +/// The decision, discriminated by `outcome`. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "outcome", rename_all = "snake_case")] +pub(super) enum RequestPermissionOutcome { + /// JP chose one of the offered options. + #[serde(rename_all = "camelCase")] + Selected { option_id: PermissionOptionId }, +} + +#[cfg(test)] +#[path = "schema_tests.rs"] +mod tests; diff --git a/crates/jp_llm/src/provider/anthropic/acp/schema_tests.rs b/crates/jp_llm/src/provider/anthropic/acp/schema_tests.rs new file mode 100644 index 000000000..c338c5e08 --- /dev/null +++ b/crates/jp_llm/src/provider/anthropic/acp/schema_tests.rs @@ -0,0 +1,288 @@ +//! Every assertion here is the protocol's spelling, written out rather than +//! round-tripped, so a renamed field fails instead of agreeing with itself. + +use serde_json::json; + +use super::*; + +#[test] +fn initialization_states_every_capability_rather_than_omitting_it() { + let request = InitializeRequest { + protocol_version: ProtocolVersion::V1, + client_capabilities: ClientCapabilities::default(), + }; + assert_eq!( + serde_json::to_value(request).unwrap(), + json!({ + "protocolVersion": 1, + "clientCapabilities": { + "fs": {"readTextFile": false, "writeTextFile": false}, + "terminal": false, + }, + }) + ); +} + +#[test] +fn an_agent_that_advertises_nothing_reads_as_supporting_nothing() { + let response: InitializeResponse = + serde_json::from_value(json!({"protocolVersion": 1})).unwrap(); + assert_eq!(response.protocol_version, ProtocolVersion::V1); + assert!(!response.agent_capabilities.load_session); + assert!(!response.agent_capabilities.mcp_capabilities.http); +} + +#[test] +fn the_two_capabilities_jp_depends_on_are_read_from_their_own_keys() { + let response: InitializeResponse = serde_json::from_value(json!({ + "protocolVersion": 1, + "agentCapabilities": { + "loadSession": true, + "promptCapabilities": {"image": true}, + "mcpCapabilities": {"http": true, "sse": true}, + }, + })) + .unwrap(); + assert!(response.agent_capabilities.load_session); + assert!(response.agent_capabilities.mcp_capabilities.http); +} + +#[test] +fn a_session_setting_is_sent_without_a_type_discriminator() { + // The protocol treats a bare `value` as an option id; a `type` here would + // select a different value shape. + let request = SetSessionConfigOptionRequest { + session_id: "sess-1".into(), + config_id: "model".into(), + value: "claude-opus-5".into(), + }; + assert_eq!( + serde_json::to_value(request).unwrap(), + json!({ + "sessionId": "sess-1", + "configId": "model", + "value": "claude-opus-5", + }) + ); +} + +#[test] +fn a_select_setting_reports_the_value_the_agent_settled_on() { + let response: SetSessionConfigOptionResponse = serde_json::from_value(json!({ + "configOptions": [{ + "id": "model", + "name": "Model", + "type": "select", + "currentValue": "claude-opus-5-20260101", + "options": [{"value": "claude-opus-5-20260101", "name": "Opus"}], + }], + })) + .unwrap(); + let [option] = response.config_options.as_slice() else { + panic!("expected one setting") + }; + assert_eq!(option.id.0, "model"); + let SessionConfigKind::Select { current_value } = &option.kind else { + panic!("expected a select") + }; + assert_eq!(current_value.0, "claude-opus-5-20260101"); +} + +#[test] +fn a_setting_shape_jp_does_not_set_still_decodes() { + let response: SetSessionConfigOptionResponse = serde_json::from_value(json!({ + "configOptions": [ + {"id": "brave_mode", "name": "Brave", "type": "boolean", "currentValue": true}, + {"id": "future", "name": "Future", "type": "something_new"}, + ], + })) + .unwrap(); + assert!( + response + .config_options + .iter() + .all(|option| matches!(option.kind, SessionConfigKind::Other)) + ); +} + +#[test] +fn a_new_tool_call_carries_its_id_and_vendor_metadata() { + let notification: SessionNotification = serde_json::from_value(json!({ + "sessionId": "sess-1", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "tc-1", + "title": "Reading configuration", + "status": "pending", + "_meta": {"claudeCode": {"toolName": "mcp__jp__lookup"}}, + }, + })) + .unwrap(); + assert_eq!(notification.session_id.0, "sess-1"); + let SessionUpdate::ToolCall(call) = notification.update else { + panic!("expected a tool call") + }; + assert_eq!(call.tool_call_id.0, "tc-1"); + assert_eq!( + call.meta.unwrap()["claudeCode"]["toolName"], + "mcp__jp__lookup" + ); +} + +#[test] +fn a_tool_call_update_reads_the_fields_the_protocol_flattens() { + // `status` and `rawInput` live in a nested object in the spec's Rust + // bindings, but sit beside `toolCallId` on the wire. + let notification: SessionNotification = serde_json::from_value(json!({ + "sessionId": "sess-1", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "tc-1", + "status": "completed", + "rawInput": {"path": "src/main.rs"}, + "content": [{"type": "content", "content": {"type": "text", "text": "ok"}}], + }, + })) + .unwrap(); + let SessionUpdate::ToolCallUpdate(update) = notification.update else { + panic!("expected a tool call update") + }; + assert_eq!(update.tool_call_id.0, "tc-1"); + assert_eq!(update.status, Some(ToolCallStatus::Completed)); + assert_eq!(update.raw_input, Some(json!({"path": "src/main.rs"}))); +} + +#[test] +fn every_other_kind_of_update_decodes_without_failing_the_connection() { + for update in [ + json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "hi"}}), + json!({"sessionUpdate": "usage_update", "used": 1, "size": 2}), + json!({"sessionUpdate": "a_kind_added_after_this_build"}), + ] { + let notification: SessionNotification = + serde_json::from_value(json!({"sessionId": "sess-1", "update": update})).unwrap(); + assert!(matches!(notification.update, SessionUpdate::Other)); + } +} + +#[test] +fn an_unfinished_tool_call_is_distinguishable_from_a_finished_one() { + for (wire, expected) in [ + ("pending", ToolCallStatus::Pending), + ("in_progress", ToolCallStatus::InProgress), + ("completed", ToolCallStatus::Completed), + ("failed", ToolCallStatus::Failed), + ("invented_later", ToolCallStatus::Other), + ] { + let status: ToolCallStatus = serde_json::from_value(json!(wire)).unwrap(); + assert_eq!(status, expected, "for {wire}"); + } +} + +#[test] +fn a_permission_request_offers_its_options_with_their_meaning() { + let request: RequestPermissionRequest = serde_json::from_value(json!({ + "sessionId": "sess-1", + "toolCall": { + "toolCallId": "tc-1", + "rawInput": {"query": "ripgrep"}, + "_meta": {"claudeCode": {"toolName": "mcp__jp__lookup"}}, + }, + "options": [ + {"optionId": "allow", "name": "Allow once", "kind": "allow_once"}, + {"optionId": "always", "name": "Always allow", "kind": "allow_always"}, + {"optionId": "no", "name": "Reject", "kind": "reject_once"}, + ], + })) + .unwrap(); + assert_eq!(request.session_id.0, "sess-1"); + assert_eq!(request.tool_call.tool_call_id.0, "tc-1"); + assert_eq!( + request.tool_call.raw_input, + Some(json!({"query": "ripgrep"})) + ); + assert_eq!( + request + .options + .iter() + .map(|option| (option.option_id.0.as_str(), option.kind)) + .collect::>(), + [ + ("allow", PermissionOptionKind::AllowOnce), + ("always", PermissionOptionKind::AllowAlways), + ("no", PermissionOptionKind::RejectOnce), + ] + ); +} + +#[test] +fn granting_permission_names_the_chosen_option_beside_the_outcome() { + let response = RequestPermissionResponse { + outcome: RequestPermissionOutcome::Selected { + option_id: "allow".into(), + }, + }; + assert_eq!( + serde_json::to_value(response).unwrap(), + json!({ + "outcome": {"outcome": "selected", "optionId": "allow"}, + }) + ); +} + +#[test] +fn session_setup_requests_name_their_fields_as_the_protocol_does() { + let new = NewSessionRequest { + cwd: "/work".into(), + mcp_servers: vec![json!({"type": "http", "name": "jp", "url": "http://127.0.0.1:1/mcp"})], + meta: Some(json!({"claudeCode": {}}).as_object().unwrap().clone()), + }; + assert_eq!( + serde_json::to_value(new).unwrap(), + json!({ + "cwd": "/work", + "mcpServers": [{"type": "http", "name": "jp", "url": "http://127.0.0.1:1/mcp"}], + "_meta": {"claudeCode": {}}, + }) + ); + + let load = LoadSessionRequest { + session_id: "sess-1".into(), + cwd: "/work".into(), + mcp_servers: vec![], + meta: None, + }; + assert_eq!( + serde_json::to_value(load).unwrap(), + json!({ + "sessionId": "sess-1", + "cwd": "/work", + "mcpServers": [], + }) + ); + + let prompt = PromptRequest { + session_id: "sess-1".into(), + prompt: vec![json!({"type": "text", "text": "Current request."})], + }; + assert_eq!( + serde_json::to_value(prompt).unwrap(), + json!({ + "sessionId": "sess-1", + "prompt": [{"type": "text", "text": "Current request."}], + }) + ); +} + +#[test] +fn responses_jp_does_not_read_decode_from_whatever_the_agent_sends() { + serde_json::from_value::(json!({})).unwrap(); + serde_json::from_value::(json!({"modes": {"currentModeId": "default"}})) + .unwrap(); + serde_json::from_value::(json!({"stopReason": "end_turn"})).unwrap(); + serde_json::from_value::(json!({ + "sessionId": "sess-1", + "modes": {"currentModeId": "default", "availableModes": []}, + })) + .unwrap(); +} diff --git a/crates/jp_llm/src/provider/anthropic/acp/snapshots/jp_llm__provider__anthropic__acp__recorded_tests__cache_reconstruction_usage.snap b/crates/jp_llm/src/provider/anthropic/acp/snapshots/jp_llm__provider__anthropic__acp__recorded_tests__cache_reconstruction_usage.snap new file mode 100644 index 000000000..f102a3969 --- /dev/null +++ b/crates/jp_llm/src/provider/anthropic/acp/snapshots/jp_llm__provider__anthropic__acp__recorded_tests__cache_reconstruction_usage.snap @@ -0,0 +1,126 @@ +--- +source: crates/jp_llm/src/provider/anthropic/acp/recorded_tests.rs +expression: "json!({\n \"initial\": anonymous(&first), \"reconstructed\": anonymous(&repeat), \"off\":\n anonymous(&off),\n})" +--- +{ + "initial": { + "native_session_id": "[session]", + "requests": { + "msg_011Cf5x1M2ZGPXyc7cnVVGoH": { + "model": "claude-opus-5", + "input_tokens": 2, + "output_tokens": 7, + "cache_creation_input_tokens": 8918, + "cache_read_input_tokens": 0, + "cache_creation": { + "ephemeral_5m_input_tokens": 0, + "ephemeral_1h_input_tokens": 8918 + } + } + }, + "runtime": { + "usage": { + "input_tokens": 2, + "output_tokens": 7, + "cache_creation_input_tokens": 8918, + "cache_read_input_tokens": 0, + "cache_creation": { + "ephemeral_5m_input_tokens": 0, + "ephemeral_1h_input_tokens": 8918 + } + }, + "model_usage": { + "claude-opus-5[1m]": { + "inputTokens": 2, + "outputTokens": 7, + "cacheReadInputTokens": 0, + "cacheCreationInputTokens": 8918, + "costUSD": 0.08936500000000001, + "contextWindow": 1000000, + "maxOutputTokens": 64000 + } + }, + "estimated_cost_usd": 0.08936500000000001 + } + }, + "reconstructed": { + "native_session_id": "[session]", + "requests": { + "msg_011Cf5x1c2htZ4Mi7o5MCbcu": { + "model": "claude-opus-5", + "input_tokens": 2, + "output_tokens": 7, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 8918, + "cache_creation": { + "ephemeral_5m_input_tokens": 0, + "ephemeral_1h_input_tokens": 0 + } + } + }, + "runtime": { + "usage": { + "input_tokens": 2, + "output_tokens": 7, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 8918, + "cache_creation": { + "ephemeral_5m_input_tokens": 0, + "ephemeral_1h_input_tokens": 0 + } + }, + "model_usage": { + "claude-opus-5[1m]": { + "inputTokens": 2, + "outputTokens": 7, + "cacheReadInputTokens": 8918, + "cacheCreationInputTokens": 0, + "costUSD": 0.0046440000000000006, + "contextWindow": 1000000, + "maxOutputTokens": 64000 + } + }, + "estimated_cost_usd": 0.0046440000000000006 + } + }, + "off": { + "native_session_id": "[session]", + "requests": { + "msg_011Cf5x1mkMHS8F25qRpcfam": { + "model": "claude-opus-5", + "input_tokens": 8920, + "output_tokens": 7, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation": { + "ephemeral_5m_input_tokens": 0, + "ephemeral_1h_input_tokens": 0 + } + } + }, + "runtime": { + "usage": { + "input_tokens": 8920, + "output_tokens": 7, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation": { + "ephemeral_5m_input_tokens": 0, + "ephemeral_1h_input_tokens": 0 + } + }, + "model_usage": { + "claude-opus-5[1m]": { + "inputTokens": 8920, + "outputTokens": 7, + "cacheReadInputTokens": 0, + "cacheCreationInputTokens": 0, + "costUSD": 0.044775, + "contextWindow": 1000000, + "maxOutputTokens": 64000 + } + }, + "estimated_cost_usd": 0.044775 + } + } +} diff --git a/crates/jp_llm/src/provider/anthropic/acp/snapshots/jp_llm__provider__anthropic__acp__transcript__tests__native_transcript.snap b/crates/jp_llm/src/provider/anthropic/acp/snapshots/jp_llm__provider__anthropic__acp__transcript__tests__native_transcript.snap new file mode 100644 index 000000000..ad4660c63 --- /dev/null +++ b/crates/jp_llm/src/provider/anthropic/acp/snapshots/jp_llm__provider__anthropic__acp__transcript__tests__native_transcript.snap @@ -0,0 +1,102 @@ +--- +source: crates/jp_llm/src/provider/anthropic/acp/transcript_tests.rs +expression: "&records" +--- +[ + { + "type": "user", + "uuid": "09b39403-d8c8-5098-ab8c-30f9d2f22b84", + "parentUuid": null, + "sessionId": "11111111-1111-4111-8111-111111111111", + "cwd": "/work/project", + "isSidechain": false, + "timestamp": "2026-09-11T12:00:00Z", + "message": { + "role": "user", + "content": [ + { + "type": "text", + "text": "Find the code." + } + ] + } + }, + { + "type": "assistant", + "uuid": "d10e833c-153d-5678-9a8a-2c942797d79e", + "parentUuid": "09b39403-d8c8-5098-ab8c-30f9d2f22b84", + "sessionId": "11111111-1111-4111-8111-111111111111", + "cwd": "/work/project", + "isSidechain": false, + "timestamp": "2026-09-11T12:00:00Z", + "message": { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "call_fixed", + "input": { + "project": "compiler" + }, + "name": "mcp__jp__lookup" + } + ], + "id": "msg_jp_d10e833c153d56789a8a2c942797d79e", + "type": "message", + "model": "claude-opus-5", + "stop_reason": "tool_use", + "stop_sequence": null, + "usage": { + "input_tokens": 0, + "output_tokens": 0 + } + } + }, + { + "type": "user", + "uuid": "05721b20-4650-50d3-96bc-1d35361a3a08", + "parentUuid": "d10e833c-153d-5678-9a8a-2c942797d79e", + "sessionId": "11111111-1111-4111-8111-111111111111", + "cwd": "/work/project", + "isSidechain": false, + "timestamp": "2026-09-11T12:00:00Z", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "call_fixed", + "content": "DOGWOOD", + "is_error": false + } + ] + } + }, + { + "type": "assistant", + "uuid": "774cbc16-be67-5e5b-ae7e-e5820bca0796", + "parentUuid": "05721b20-4650-50d3-96bc-1d35361a3a08", + "sessionId": "11111111-1111-4111-8111-111111111111", + "cwd": "/work/project", + "isSidechain": false, + "timestamp": "2026-09-11T12:00:00Z", + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "Found it." + } + ], + "id": "msg_jp_774cbc16be675e5bae7ee5820bca0796", + "type": "message", + "model": "claude-opus-5", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 0, + "output_tokens": 0 + } + } + } +] diff --git a/crates/jp_llm/src/provider/anthropic/acp/snapshots/jp_llm__provider__anthropic__acp__transport__tests__session_options.snap b/crates/jp_llm/src/provider/anthropic/acp/snapshots/jp_llm__provider__anthropic__acp__transport__tests__session_options.snap new file mode 100644 index 000000000..9b4035b57 --- /dev/null +++ b/crates/jp_llm/src/provider/anthropic/acp/snapshots/jp_llm__provider__anthropic__acp__transport__tests__session_options.snap @@ -0,0 +1,41 @@ +--- +source: crates/jp_llm/src/provider/anthropic/acp/transport_tests.rs +expression: "params[\"_meta\"]" +--- +{ + "claudeCode": { + "emitRawSDKMessages": true, + "options": { + "systemPrompt": { + "type": "custom", + "prompt": "Use JP's history.", + "snapshot": false + }, + "model": "claude-opus-5", + "tools": [], + "allowedTools": [], + "strictMcpConfig": true, + "settingSources": [], + "settings": { + "disableAllHooks": true, + "autoMemoryEnabled": false, + "permissions": { + "ask": [ + "mcp__jp__*" + ] + } + }, + "persistSession": true, + "env": { + "CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1", + "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS": "1", + "CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS": "0", + "CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT": "0", + "DISABLE_AUTO_COMPACT": "1", + "ENABLE_TOOL_SEARCH": "false", + "MAX_MCP_OUTPUT_TOKENS": "100000", + "MCP_TOOL_TIMEOUT": "2147483647" + } + } + } +} diff --git a/crates/jp_llm/src/provider/anthropic/acp/snapshots/jp_llm__provider__anthropic__acp__workflow_tests__compacted_native_history.snap b/crates/jp_llm/src/provider/anthropic/acp/snapshots/jp_llm__provider__anthropic__acp__workflow_tests__compacted_native_history.snap new file mode 100644 index 000000000..3b62624b5 --- /dev/null +++ b/crates/jp_llm/src/provider/anthropic/acp/snapshots/jp_llm__provider__anthropic__acp__workflow_tests__compacted_native_history.snap @@ -0,0 +1,24 @@ +--- +source: crates/jp_llm/src/provider/anthropic/acp/workflow_tests.rs +expression: prepared.history +--- +[ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "[Summary of previous conversation]" + } + ] + }, + { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "Lookup and verification established OAK." + } + ] + } +] diff --git a/crates/jp_llm/src/provider/anthropic/acp/transcript.rs b/crates/jp_llm/src/provider/anthropic/acp/transcript.rs new file mode 100644 index 000000000..4c5ccba98 --- /dev/null +++ b/crates/jp_llm/src/provider/anthropic/acp/transcript.rs @@ -0,0 +1,277 @@ +//! Claude-native records derived from the current JP Thread. +//! +//! The Anthropic request builder owns role grouping, projection, attachment +//! conversion, and reasoning compatibility. +//! This encoder preserves that content and adds disposable native bookkeeping +//! without rewriting tool-call IDs. + +use async_anthropic::types::{ + Effort, ExtendedThinking, JsonOutputFormat, Message, MessageContent, MessageContentList, + MessageRole, System, SystemContent, +}; +use camino::Utf8Path; +use chrono::{DateTime, Utc}; +use jp_config::{ + PartialAppConfig, + model::{id::Name, parameters::ServiceTier}, +}; +use serde::Serialize; +use serde_json::{Map, Value}; +use tracing::warn; +use uuid::Uuid; + +use crate::{ + error::Result, + model::ModelDetails, + provider::anthropic::{BetaFeatures, CONTINUE_MESSAGE, create_request}, + query::ChatQuery, +}; + +/// Input for one isolated ACP session and its single pending prompt. +pub(super) struct PreparedRequest { + pub system_prompt: String, + pub history: Vec, + pub prompt: String, + pub model: Name, + /// Explicit JP limit. + /// Absence leaves the runtime's output limit unchanged. + pub max_tokens: Option, + pub thinking: Option, + pub effort: Option, + pub schema: Option>, +} + +impl PreparedRequest { + pub(super) fn new(model: &ModelDetails, mut query: ChatQuery) -> Result { + let config = query.thread.events.config()?; + let parameters = &config.assistant.model.parameters; + let max_tokens = parameters.max_tokens; + for (parameter, configured) in [ + ("temperature", parameters.temperature.is_some()), + ("top_p", parameters.top_p.is_some()), + ("top_k", parameters.top_k.is_some()), + ("stop_words", !parameters.stop_words.is_empty()), + ( + "service_tier", + parameters + .service_tier + .is_some_and(|tier| tier != ServiceTier::Off), + ), + ] { + if configured { + warn!( + parameter, + "Ignoring unsupported model parameter for the ACP subscription flow" + ); + } + } + for parameter in parameters.other.keys() { + warn!( + parameter, + "Ignoring unsupported model parameter for the ACP subscription flow" + ); + } + // API-tier validation must not reject a request whose tier is omitted + // from the SDK options. This delta affects only the owned request view. + if parameters + .service_tier + .is_some_and(|tier| tier != ServiceTier::Off) + { + let mut delta = PartialAppConfig::default(); + delta.assistant.model.parameters.service_tier = Some(ServiceTier::Off); + query.thread.events.add_config_delta(delta); + } + let beta = BetaFeatures( + query + .thread + .events + .config()? + .providers + .llm + .anthropic + .beta_headers + .clone(), + ); + let (mut request, _, _) = create_request(model, query, true, &beta, false)?; + let system_prompt = match request.system.take() { + Some(System::String(text)) => text, + Some(System::Content(blocks)) => blocks + .into_iter() + .map(|block| { + let SystemContent::Text(text) = block; + text.text + }) + .collect::>() + .join("\n\n"), + None => String::new(), + }; + let prompt = take_pending_text(&mut request.messages); + for message in &mut request.messages { + for block in &mut message.content.0 { + match block { + MessageContent::Text(text) => text.cache_control = None, + MessageContent::ToolUse(call) => { + call.name = tool_name(&call.name); + call.cache_control = None; + } + MessageContent::ToolResult(result) => result.cache_control = None, + MessageContent::Document(document) => document.cache_control = None, + MessageContent::Thinking(_) | MessageContent::RedactedThinking { .. } => {} + } + } + } + let (effort, schema) = request.output_config.map_or((None, None), |output| { + ( + output.effort, + output.format.map(|format| { + let JsonOutputFormat::JsonSchema { schema } = format; + schema + }), + ) + }); + Ok(Self { + system_prompt, + history: request.messages, + prompt, + model: model.id.name.clone(), + max_tokens, + thinking: request.thinking, + effort, + schema, + }) + } + + pub(super) fn records<'a>( + &'a self, + session: Uuid, + cwd: &'a Utf8Path, + timestamp: DateTime, + ) -> Vec> { + let mut parent = None; + self.history + .iter() + .enumerate() + .map(|(index, message)| { + let uuid = Uuid::new_v5(&session, &index.to_le_bytes()); + let record = NativeRecord { + type_: message.role.clone(), + uuid, + parent_uuid: parent, + session_id: session, + cwd, + is_sidechain: false, + timestamp, + message: match message.role { + MessageRole::User => NativeMessage::User { + content: &message.content, + }, + MessageRole::Assistant => NativeMessage::Assistant { + content: &message.content, + id: format!("msg_jp_{}", uuid.simple()), + type_: MessageType::Message, + model: &self.model, + stop_reason: if message + .content + .0 + .iter() + .any(|block| matches!(block, MessageContent::ToolUse(_))) + { + StopReason::ToolUse + } else { + StopReason::EndTurn + }, + stop_sequence: None, + usage: NativeUsage { + input_tokens: 0, + output_tokens: 0, + }, + }, + }, + }; + parent = Some(uuid); + record + }) + .collect() + } +} + +/// MCP name assigned by Claude Code to a tool advertised by the `jp` server. +pub(super) fn tool_name(name: &str) -> String { + format!("mcp__jp__{name}") +} + +fn take_pending_text(messages: &mut Vec) -> String { + let Some(last) = messages.last_mut() else { + return CONTINUE_MESSAGE.to_owned(); + }; + if last.role != MessageRole::User + || !matches!(last.content.0.last(), Some(MessageContent::Text(_))) + { + return CONTINUE_MESSAGE.to_owned(); + } + let Some(MessageContent::Text(text)) = last.content.0.pop() else { + unreachable!("the last block was checked above") + }; + if last.content.0.is_empty() { + messages.pop(); + } + text.text +} + +/// A record in the qualified Claude Code JSONL format. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct NativeRecord<'a> { + #[serde(rename = "type")] + pub type_: MessageRole, + pub uuid: Uuid, + pub parent_uuid: Option, + pub session_id: Uuid, + pub cwd: &'a Utf8Path, + pub is_sidechain: bool, + pub timestamp: DateTime, + pub message: NativeMessage<'a>, +} + +/// Model-visible content with bookkeeping restricted to assistant messages. +#[derive(Serialize)] +#[serde(tag = "role", rename_all = "snake_case")] +pub(super) enum NativeMessage<'a> { + User { + content: &'a MessageContentList, + }, + Assistant { + content: &'a MessageContentList, + id: String, + #[serde(rename = "type")] + type_: MessageType, + model: &'a Name, + stop_reason: StopReason, + stop_sequence: Option, + usage: NativeUsage, + }, +} + +#[derive(Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum MessageType { + Message, +} + +#[derive(Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum StopReason { + EndTurn, + ToolUse, +} + +/// Native history bookkeeping, not a claim about previously billed usage. +#[derive(Serialize)] +pub(super) struct NativeUsage { + input_tokens: u64, + output_tokens: u64, +} + +#[cfg(test)] +#[path = "transcript_tests.rs"] +mod tests; diff --git a/crates/jp_llm/src/provider/anthropic/acp/transcript_tests.rs b/crates/jp_llm/src/provider/anthropic/acp/transcript_tests.rs new file mode 100644 index 000000000..f7d84a320 --- /dev/null +++ b/crates/jp_llm/src/provider/anthropic/acp/transcript_tests.rs @@ -0,0 +1,138 @@ +use async_anthropic::types::{MessageContent, MessageRole}; +use camino::Utf8Path; +use datetime_literal::datetime; +use jp_config::{AppConfig, PartialAppConfig}; +use jp_conversation::{ + ConversationStream, + event::{ChatRequest, ChatResponse, ConversationEvent, ToolCallRequest, ToolCallResponse}, + thread::ThreadBuilder, +}; +use serde_json::{Map, json}; +use uuid::Uuid; + +use super::*; + +fn query() -> ChatQuery { + let timestamp = datetime!(2026-09-11 12:00:00 Z); + let mut stream = + ConversationStream::new(AppConfig::new_test().into()).with_created_at(timestamp); + stream.extend([ + ConversationEvent::new(ChatRequest::from("Find the code."), timestamp), + ConversationEvent::new( + ToolCallRequest::new( + "call_fixed".into(), + "lookup".into(), + Map::from_iter([("project".into(), "compiler".into())]), + ), + timestamp, + ), + ConversationEvent::new( + ToolCallResponse { + id: "call_fixed".into(), + result: Ok("DOGWOOD".into()), + }, + timestamp, + ), + ConversationEvent::new(ChatResponse::message("Found it."), timestamp), + ConversationEvent::new(ChatRequest::from("What is the code?"), timestamp), + ]); + ThreadBuilder::new() + .with_system_prompt("Use the supplied history.") + .with_events(stream) + .build() + .unwrap() + .into() +} + +#[test] +fn thread_prefix_retains_roles_and_tool_pairing() { + let model = super::super::model_details(&"claude-opus-5".parse().unwrap()); + let prepared = PreparedRequest::new(&model, query()).unwrap(); + assert_eq!(prepared.prompt, "What is the code?"); + assert_eq!(prepared.system_prompt, "Use the supplied history."); + assert_eq!( + serde_json::to_value(&prepared.history).unwrap(), + json!([ + {"role":"user","content":[{"type":"text","text":"Find the code."}]}, + {"role":"assistant","content":[{"type":"tool_use","id":"call_fixed","name":"mcp__jp__lookup","input":{"project":"compiler"}}]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":"call_fixed","content":"DOGWOOD","is_error":false}]}, + {"role":"assistant","content":[{"type":"text","text":"Found it."}]} + ]) + ); +} + +#[test] +fn native_bookkeeping_does_not_rewrite_message_content() { + let model = super::super::model_details(&"claude-opus-5".parse().unwrap()); + let prepared = PreparedRequest::new(&model, query()).unwrap(); + let session = Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap(); + let records = prepared.records( + session, + Utf8Path::new("/work/project"), + datetime!(2026-09-11 12:00:00 Z), + ); + assert_eq!(records[0].parent_uuid, None); + assert_eq!(records[1].parent_uuid, Some(records[0].uuid)); + assert_eq!(records[3].session_id, session); + assert_eq!(records[1].type_, MessageRole::Assistant); + insta::assert_json_snapshot!("native_transcript", &records); + assert_eq!( + serde_json::to_value(&records[1].message).unwrap()["content"], + json!([ + {"type":"tool_use","id":"call_fixed","name":"mcp__jp__lookup","input":{"project":"compiler"}} + ]) + ); +} + +#[test] +fn session_ids_and_timestamps_do_not_change_the_model_visible_prefix() { + let model = super::super::model_details(&"claude-opus-5".parse().unwrap()); + let prepared = PreparedRequest::new(&model, query()).unwrap(); + let first = prepared.records( + Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap(), + Utf8Path::new("/work/project"), + datetime!(2026-09-11 12:00:00 Z), + ); + let second = prepared.records( + Uuid::parse_str("22222222-2222-4222-8222-222222222222").unwrap(), + Utf8Path::new("/work/project"), + datetime!(2026-09-11 12:01:00 Z), + ); + let content = |records: Vec>| { + records + .into_iter() + .map(|record| { + let value = serde_json::to_value(record).unwrap(); + json!({"role":value["message"]["role"],"content":value["message"]["content"]}) + }) + .collect::>() + }; + assert_ne!(first[0].uuid, second[0].uuid); + assert_eq!(content(first), content(second)); +} + +#[test] +fn unsupported_parameters_do_not_block_subscription_requests() { + let mut query = query(); + query.thread.events.add_config_delta(serde_json::from_value::(json!({"assistant":{"model":{"parameters":{"temperature":0.4,"top_p":0.8,"top_k":10,"service_tier":"flex","custom_option":true}}}})).unwrap()); + let model = super::super::model_details(&"claude-opus-5".parse().unwrap()); + let prepared = PreparedRequest::new(&model, query).unwrap(); + assert_eq!(prepared.prompt, "What is the code?"); +} + +#[test] +fn pending_text_is_removed_without_removing_prior_user_blocks() { + let mut query = query(); + query.thread.events.extend([ConversationEvent::new( + ChatRequest::from("One more instruction."), + datetime!(2026-09-11 12:00:01 Z), + )]); + let model = super::super::model_details(&"claude-opus-5".parse().unwrap()); + let prepared = PreparedRequest::new(&model, query).unwrap(); + assert_eq!(prepared.prompt, "One more instruction."); + let last = prepared.history.last().unwrap(); + assert_eq!(last.role, MessageRole::User); + assert_eq!(last.content.0, vec![MessageContent::Text( + "What is the code?".into() + )]); +} diff --git a/crates/jp_llm/src/provider/anthropic/acp/transport.rs b/crates/jp_llm/src/provider/anthropic/acp/transport.rs new file mode 100644 index 000000000..51f174510 --- /dev/null +++ b/crates/jp_llm/src/provider/anthropic/acp/transport.rs @@ -0,0 +1,606 @@ +//! One isolated ACP connection per provider request. + +use std::{ + collections::BTreeMap, + env, fs, + io::{self, Write as _}, + sync::{Arc, Mutex, PoisonError}, + time::Duration, +}; + +use camino::{Utf8Path, Utf8PathBuf}; +use chrono::Utc; +use futures::{StreamExt as _, future::BoxFuture}; +use jp_config::assistant::{request::CachePolicy, tool_choice::ToolChoice}; +use serde::de::DeserializeOwned; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; +use tokio::sync::mpsc; +use tracing::{debug, instrument::WithSubscriber as _, warn}; +use uuid::Uuid; + +use super::{ + Error, cassette, options, process, + protocol::{AuthUpdate, SdkNotification, State}, + rpc::{Handler, Inbound, Peer, Request, RpcError, Tap}, + schema::{ + ClientCapabilities, InitializeRequest, InitializeResponse, LoadSessionRequest, + LoadSessionResponse, NewSessionRequest, NewSessionResponse, PromptRequest, PromptResponse, + ProtocolVersion, RequestPermissionRequest, SessionConfigKind, SessionNotification, + SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, agent_method, client_method, + }, + transcript::PreparedRequest, +}; +use crate::{ + error::StreamError, + event::Event, + model::ModelDetails, + query::{ChatQuery, QueryContext}, + stream::EventStream, +}; + +/// Launch work independently of stream polling so MCP tools can complete while +/// the Host is processing their events. +/// Dropping the stream closes the channel and cancels the connection, including +/// the SDK-owned process group. +pub(crate) fn stream( + model: &ModelDetails, + query: ChatQuery, + context: QueryContext, +) -> crate::error::Result { + let cache = query.thread.events.config()?.assistant.request.cache; + let tools = if query.tool_choice == ToolChoice::None { + vec![] + } else { + query + .tools + .iter() + .map(|tool| tool.name.clone()) + .collect::>() + }; + if !tools.is_empty() && context.mcp_endpoint.is_none() { + return Err(Error::ToolHostRequired.into()); + } + let prepared = PreparedRequest::new(model, query)?; + Ok(async_stream::stream! { + let (sender, mut receiver) = mpsc::channel(32); + tokio::spawn(async move { + let result = tokio::select! { + biased; + () = sender.closed() => return, + result = run(prepared, context, tools, cache, sender.clone()) => result, + }; + if let Err(error) = result { + let error = match error { + Error::Stream(error) => *error, + error => StreamError::other(error.to_string()).with_source(error), + }; + drop(sender.send(Err(error)).await); + } + }.with_current_subscriber()); + while let Some(event) = receiver.recv().await { yield event; } + } + .boxed()) +} + +// The requests JP issues, paired with the answers the adapter returns. +impl Request for InitializeRequest { + const METHOD: &'static str = agent_method::INITIALIZE; + + type Response = InitializeResponse; +} + +impl Request for NewSessionRequest { + const METHOD: &'static str = agent_method::SESSION_NEW; + + type Response = NewSessionResponse; +} + +impl Request for LoadSessionRequest { + const METHOD: &'static str = agent_method::SESSION_LOAD; + + type Response = LoadSessionResponse; +} + +impl Request for SetSessionConfigOptionRequest { + const METHOD: &'static str = agent_method::SESSION_SET_CONFIG_OPTION; + + type Response = SetSessionConfigOptionResponse; +} + +impl Request for PromptRequest { + const METHOD: &'static str = agent_method::SESSION_PROMPT; + + type Response = PromptResponse; +} + +/// Establishes one connection and runs it until the request sequence finishes. +/// +/// [`Spawned`] is what production uses. +/// A test supplies `cassette::Recorded`, which answers from a recording over an +/// in-memory pipe, so the same handler and foreground run either way. +/// +/// Consumed by connecting, since one of these describes one connection. +pub(super) trait Transport: Send { + fn connect( + self: Box, + handler: Handler, + foreground: Foreground, + ) -> BoxFuture<'static, Result<(), RpcError>>; +} + +/// Any closure with the right shape, so a test can script one inline rather +/// than declare a type for it. +impl Transport for F +where + F: FnOnce(Handler, Foreground) -> BoxFuture<'static, Result<(), RpcError>> + Send, +{ + fn connect( + self: Box, + handler: Handler, + foreground: Foreground, + ) -> BoxFuture<'static, Result<(), RpcError>> { + (*self)(handler, foreground) + } +} + +/// The request sequence JP drives once the connection is up. +pub(super) type Foreground = + Box BoxFuture<'static, Result<(), RpcError>> + Send>; + +/// Spawn the adapter and speak ACP over its stdio. +/// +/// `tap` observes the conversation; [`cassette::tap`] supplies one that records +/// under `RECORD`, and an inert one otherwise. +pub(super) struct Spawned { + pub(super) command: tokio::process::Command, + pub(super) tap: Tap, +} + +impl Transport for Spawned { + fn connect( + self: Box, + handler: Handler, + foreground: Foreground, + ) -> BoxFuture<'static, Result<(), RpcError>> { + Box::pin(process::run(self.command, self.tap, handler, foreground)) + } +} + +fn decode(params: Value) -> Result { + serde_json::from_value(params).map_err(RpcError::into_internal_error) +} + +async fn emit( + sender: &mpsc::Sender>, + events: Vec, +) -> Result<(), RpcError> { + for event in events { + sender + .send(Ok(event)) + .await + .map_err(|_| RpcError::internal_error().data("JP event receiver closed"))?; + } + Ok(()) +} + +fn record_failure(state: &Mutex, error: StreamError) -> RpcError { + let response = RpcError::into_internal_error(&error); + state + .lock() + .unwrap_or_else(PoisonError::into_inner) + .failure + .get_or_insert(error); + response +} + +/// What a connection needs on disk and in the child's environment before it can +/// reach the adapter. +pub(super) struct Launch { + /// The derived transcript the adapter resumes from, removed when dropped. + pub(super) artifact: NativeArtifact, + + /// The environment the adapter is given, and that the session options are + /// derived from. + pub(super) environment: BTreeMap, + + /// The adapter, ready to spawn. + pub(super) command: tokio::process::Command, +} + +/// Prepare one connection: write its transcript, build its environment, and +/// construct the command that would spawn the adapter. +/// +/// Reads `HOME` and `CLAUDE_CONFIG_DIR`, and writes into the directory they +/// name, so a caller that has no adapter to run should build its own pieces +/// rather than call this. +pub(super) fn launch( + prepared: &PreparedRequest, + context: &QueryContext, + cache: CachePolicy, +) -> Result { + let directory = native_directory()?; + let project = project_name(context); + let artifact = NativeArtifact::write(prepared, &context.root, &directory, &project)?; + let mut environment = options::environment(prepared, cache); + configure_storage_environment( + &mut environment, + env::var("CLAUDE_CONFIG_DIR").ok().as_deref(), + &project, + ); + let mut command = process::command(); + command.envs(&environment); + + Ok(Launch { + artifact, + environment, + command, + }) +} + +async fn run( + prepared: PreparedRequest, + context: QueryContext, + tools: Vec, + cache: CachePolicy, + sender: mpsc::Sender>, +) -> Result<(), Error> { + let Launch { + artifact, + environment, + command, + } = launch(&prepared, &context, cache)?; + + drive( + prepared, + context, + tools, + environment, + artifact, + Box::new(Spawned { + command, + tap: cassette::tap("live"), + }), + sender, + ) + .await +} + +/// Route everything the adapter initiates into the shared translation state. +/// +/// An error here ends the connection, which is how a revoked subscription stops +/// a prompt that is already streaming. +fn inbound(state: Arc>, sender: mpsc::Sender>) -> Handler { + Box::new(move |message| { + let state = state.clone(); + let sender = sender.clone(); + Box::pin(async move { + let (Inbound::Notification { method, params } | Inbound::Request { method, params }) = + message; + + if method == AuthUpdate::METHOD { + let notification: AuthUpdate = decode(params)?; + let mut locked = state.lock().unwrap_or_else(PoisonError::into_inner); + locked.authenticated = notification.auth_status.is_subscription(); + if locked.live && !locked.authenticated { + return Err(RpcError::into_internal_error(Error::SubscriptionRequired)); + } + return Ok(Value::Null); + } + + if method == SdkNotification::METHOD { + let notification: SdkNotification = decode(params)?; + let (active, result) = { + let mut locked = state.lock().unwrap_or_else(PoisonError::into_inner); + let active = + locked.live && locked.session.as_ref() == Some(¬ification.session_id); + (active, locked.sdk(notification)) + }; + let mut events = result.map_err(|error| record_failure(&state, error))?; + // Non-rendered SDK updates still prove the connection is active. + if active && events.is_empty() { + events.push(Event::KeepAlive); + } + emit(&sender, events).await?; + return Ok(Value::Null); + } + + if method == client_method::SESSION_UPDATE { + let notification: SessionNotification = decode(params)?; + let active = { + let mut locked = state.lock().unwrap_or_else(PoisonError::into_inner); + let active = + locked.live && locked.session.as_ref() == Some(¬ification.session_id); + locked.observe(notification); + active + }; + if active { + emit(&sender, vec![Event::KeepAlive]).await?; + } + return Ok(Value::Null); + } + + if method == client_method::SESSION_REQUEST_PERMISSION { + let request: RequestPermissionRequest = decode(params)?; + let (response, events) = state + .lock() + .unwrap_or_else(PoisonError::into_inner) + .permission(request) + .map_err(RpcError::into_internal_error)?; + emit(&sender, events).await?; + return serde_json::to_value(response).map_err(RpcError::into_internal_error); + } + + // The adapter reports more than JP reads, and answering an unknown + // request with null is friendlier than failing the connection. + debug!(%method, "Ignoring unhandled ACP message"); + Ok(Value::Null) + }) + }) +} + +#[expect( + clippy::too_many_lines, + reason = "The session setup is one ordered sequence; splitting it hides the order" +)] +pub(super) async fn drive( + prepared: PreparedRequest, + context: QueryContext, + tools: Vec, + environment: BTreeMap, + artifact: NativeArtifact, + transport: Box, + sender: mpsc::Sender>, +) -> Result<(), Error> { + let state = Arc::new(Mutex::new(State::new( + prepared.model.clone(), + tools.iter().cloned(), + prepared.schema.is_some(), + ))); + let handler = inbound(state.clone(), sender.clone()); + let foreground_state = state.clone(); + let heartbeat = sender.clone(); + let foreground: Foreground = Box::new(move |peer| { + Box::pin(async move { + let init = peer + .request(InitializeRequest { + protocol_version: ProtocolVersion::V1, + client_capabilities: ClientCapabilities::default(), + }) + .await?; + if init.protocol_version != ProtocolVersion::V1 + || (!tools.is_empty() && !init.agent_capabilities.mcp_capabilities.http) + { + return Err(RpcError::into_internal_error( + Error::InitializationCapabilities, + )); + } + let servers = if tools.is_empty() { + vec![] + } else { + vec![ + json!({"type":"http","name":"jp","url":context.mcp_endpoint.as_ref().expect("tool host validated").as_str(),"headers":[]}), + ] + }; + let options = options::metadata(&prepared, &environment) + .map_err(RpcError::into_internal_error)?; + let session = if let Some(id) = artifact.session { + if !init.agent_capabilities.load_session { + return Err(RpcError::into_internal_error( + Error::HistoryLoadingUnsupported, + )); + } + let request: LoadSessionRequest = serde_json::from_value( + json!({"sessionId":id,"cwd":context.root,"mcpServers":servers,"_meta":options}), + ) + .map_err(RpcError::into_internal_error)?; + peer.request(request).await?; + id.to_string().into() + } else { + let request: NewSessionRequest = serde_json::from_value( + json!({"cwd":context.root,"mcpServers":servers,"_meta":options}), + ) + .map_err(RpcError::into_internal_error)?; + peer.request(request).await?.session_id + }; + for (config_id, value) in [("model", prepared.model.as_ref()), ("mode", "default")] { + let request: SetSessionConfigOptionRequest = serde_json::from_value( + json!({"sessionId":session,"configId":config_id,"value":value}), + ) + .map_err(RpcError::into_internal_error)?; + let response = peer.request(request).await.map_err(|source| { + if config_id != "model" { + return source; + } + let error = Error::ModelSelection { + model: prepared.model.clone(), + source, + }; + record_failure( + &foreground_state, + StreamError::other(error.to_string()).with_source(error), + ) + })?; + // Claude Code can normalize a model alias to a canonical identifier. + let applied = response.config_options.iter().any(|option| option.id.0 == config_id + && matches!(&option.kind, SessionConfigKind::Select { current_value } if + (config_id == "model" && !current_value.0.is_empty()) || current_value.0 == value)); + if !applied { + return Err(RpcError::into_internal_error(Error::SettingNotApplied { + setting: config_id.into(), + })); + } + } + { + let mut state = foreground_state + .lock() + .unwrap_or_else(PoisonError::into_inner); + if !state.authenticated { + return Err(RpcError::into_internal_error(Error::SubscriptionRequired)); + } + state.session = Some(session.clone()); + state.live = true; + } + let request: PromptRequest = serde_json::from_value( + json!({"sessionId":session,"prompt":[{"type":"text","text":prepared.prompt}]}), + ) + .map_err(RpcError::into_internal_error)?; + peer.request(request).await?; + let events = { + let mut state = foreground_state + .lock() + .unwrap_or_else(PoisonError::into_inner); + state + .final_events + .take() + .ok_or_else(|| RpcError::into_internal_error(Error::MissingSdkResult))? + }; + emit(&sender, events).await + }) + }); + let result = transport.connect(handler, foreground); + tokio::pin!(result); + let mut tick = tokio::time::interval(Duration::from_secs(5)); + loop { + tokio::select! { + result = &mut result => { + debug!(usage = %state.lock().unwrap_or_else(PoisonError::into_inner).usage_snapshot(), "Claude ACP usage snapshot"); + if let Some(error) = state.lock().unwrap_or_else(PoisonError::into_inner).failure.take() { + return Err(Error::Stream(Box::new(error))); + } + return result.map_err(Error::Protocol); + }, + _ = tick.tick() => { + // Anthropic can buffer an entire argument value. Keep the same + // liveness policy as the direct flow while that block is open. + let pending = state.lock().unwrap_or_else(PoisonError::into_inner).has_tool_activity(); + if pending { + emit(&heartbeat, vec![Event::KeepAlive]).await.map_err(Error::Protocol)?; + } + } + } + } +} + +fn configure_storage_environment( + environment: &mut BTreeMap, + configured: Option<&str>, + project: &str, +) { + // Setting CLAUDE_CONFIG_DIR can select a different Keychain entry even + // when it names the default directory. Preserve the login environment. + // Without an explicit config directory, resume finds our file by ID. + if configured.is_some() { + environment.insert("CLAUDE_CODE_PROJECT_DIR_NAME".into(), project.into()); + } +} + +fn project_name(context: &QueryContext) -> String { + if let Some(invocation) = &context.invocation { + let name = format!( + "jp-{}-{}", + invocation.conversation_id, invocation.workspace_id + ); + if name.len() <= 64 + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return name; + } + return format!( + "jp-{}", + &format!("{:x}", Sha256::digest(name.as_bytes()))[..48] + ); + } + // Auxiliary requests have no conversation binding; their session files + // remain independent even when they share this storage directory. + format!( + "jp-aux-{}", + &format!("{:x}", Sha256::digest(context.root.as_str().as_bytes()))[..48] + ) +} + +fn native_directory() -> Result { + let directory = env::var("CLAUDE_CONFIG_DIR") + .or_else(|_| { + #[cfg(windows)] + let home = env::var("USERPROFILE").or_else(|_| env::var("HOME")); + #[cfg(not(windows))] + let home = env::var("HOME"); + home.map(|home| format!("{home}/.claude")) + }) + .map(Utf8PathBuf::from) + .map_err(|_| Error::NativeDirectory)?; + if !directory.is_absolute() { + return Err(Error::NativeDirectory); + } + Ok(directory) +} + +/// The transcript a connection resumes from. +/// +/// `session` decides which request JP opens with: `session/load` when there is +/// one to resume, `session/new` otherwise. +/// `path` is the file backing it, which only a run with an adapter to read it +/// needs. +pub(super) struct NativeArtifact { + pub(super) session: Option, + pub(super) path: Option, +} + +impl NativeArtifact { + fn write( + prepared: &PreparedRequest, + root: &Utf8Path, + directory: &Utf8Path, + project: &str, + ) -> Result { + if prepared.history.is_empty() { + return Ok(Self { + session: None, + path: None, + }); + } + if !root.is_absolute() { + return Err(Error::NativeDirectory); + } + let directory = directory.join("projects").join(project); + fs::create_dir_all(&directory).map_err(Error::NativeIo)?; + let session = Uuid::new_v4(); + let path = directory.join(format!("{session}.jsonl")); + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let mut file = options.open(&path).map_err(Error::NativeIo)?; + let artifact = Self { + session: Some(session), + path: Some(path), + }; + for record in prepared.records(session, root, Utc::now()) { + serde_json::to_writer(&mut file, &record).map_err(Error::NativeJson)?; + file.write_all(b"\n").map_err(Error::NativeIo)?; + } + file.flush().map_err(Error::NativeIo)?; + Ok(artifact) + } +} + +impl Drop for NativeArtifact { + fn drop(&mut self) { + if let Some(path) = &self.path + && let Err(error) = fs::remove_file(path) + && error.kind() != io::ErrorKind::NotFound + { + warn!(%error, %path, "Could not remove derived Claude transcript"); + } + } +} + +#[cfg(test)] +#[path = "transport_tests.rs"] +mod tests; diff --git a/crates/jp_llm/src/provider/anthropic/acp/transport_tests.rs b/crates/jp_llm/src/provider/anthropic/acp/transport_tests.rs new file mode 100644 index 000000000..06762649b --- /dev/null +++ b/crates/jp_llm/src/provider/anthropic/acp/transport_tests.rs @@ -0,0 +1,605 @@ +use std::{error::Error as _, iter}; + +use datetime_literal::datetime; +use jp_config::AppConfig; +use jp_conversation::{ + ConversationStream, + event::{ChatRequest, ChatResponse, ConversationEvent}, + thread::ThreadBuilder, +}; +use jp_tool::InvocationContext; +use serde_json::{Map, Value, json}; +use tokio::{ + io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader}, + sync::Notify, + task::JoinHandle, + time::{advance, timeout}, +}; +use tracing::instrument::WithSubscriber as _; +use tracing_subscriber::{layer::SubscriberExt as _, registry}; + +use super::{super::recorded_tests::UsageCapture, *}; +use crate::event::{EventPart, FinishReason}; + +fn prepared() -> PreparedRequest { + prepared_with_limit(None) +} + +fn prepared_with_limit(max_tokens: Option) -> PreparedRequest { + let mut config = AppConfig::new_test(); + config.assistant.model.parameters.max_tokens = max_tokens; + let timestamp = datetime!(2026-09-11 12:00:00 Z); + let mut events = ConversationStream::new(config.into()).with_created_at(timestamp); + events.extend([ + ConversationEvent::new(ChatRequest::from("Earlier input."), timestamp), + ConversationEvent::new(ChatResponse::message("Earlier response."), timestamp), + ConversationEvent::new(ChatRequest::from("Current request."), timestamp), + ]); + let thread = ThreadBuilder::new() + .with_system_prompt("Use JP's history.") + .with_events(events) + .build() + .unwrap(); + let model = super::super::model_details(&"claude-opus-5".parse().unwrap()); + PreparedRequest::new(&model, thread.into()).unwrap() +} + +fn notification(message: Value) -> SdkNotification { + SdkNotification { + session_id: "11111111-1111-4111-8111-111111111111".into(), + message: serde_json::from_value(message).unwrap(), + } +} + +/// Pushes notifications to JP while a request of its own is still open. +#[derive(Clone)] +struct Notifier(mpsc::UnboundedSender); + +impl Notifier { + fn notify(&self, method: &str, params: &Value) { + drop( + self.0 + .send(json!({"jsonrpc": "2.0", "method": method, "params": params})), + ); + } + + /// One `_claude/sdkMessage`, the channel Claude Code streams through. + fn sdk(&self, message: Value) { + let params = serde_json::to_value(notification(message)).unwrap(); + self.notify(SdkNotification::METHOD, ¶ms); + } + + fn auth(&self, plan: &str) { + self.notify( + AuthUpdate::METHOD, + &json!({"authStatus": {"kind": "account", "account": {"plan": plan}}}), + ); + } +} + +/// An adapter scripted at the wire level. +/// +/// `respond` answers each request JP sends, by method, and may push +/// notifications through its [`Notifier`] before returning. +/// Everything crosses a real pipe as newline-delimited JSON, so the framing is +/// under test rather than bypassed. +fn scripted(respond: F) -> Box +where + F: Fn(String, Value, Notifier) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, +{ + Box::new(move |handler, foreground| { + let (jp_writes, agent_reads) = tokio::io::duplex(1 << 16); + let (agent_writes, jp_reads) = tokio::io::duplex(1 << 16); + let (outgoing, mut queued) = mpsc::unbounded_channel::(); + let notifier = Notifier(outgoing.clone()); + + tokio::spawn(async move { + let mut agent_writes = agent_writes; + while let Some(message) = queued.recv().await { + let mut line = serde_json::to_vec(&message).unwrap(); + line.push(b'\n'); + if agent_writes.write_all(&line).await.is_err() { + break; + } + drop(agent_writes.flush().await); + } + }); + + tokio::spawn(async move { + let mut lines = BufReader::new(agent_reads).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let message: Value = serde_json::from_str(&line).unwrap(); + let Some(id) = message.get("id").cloned() else { + continue; + }; + let method = message["method"].as_str().unwrap().to_owned(); + let params = message.get("params").cloned().unwrap_or(Value::Null); + let reply = match respond(method, params, notifier.clone()).await { + Ok(result) => json!({"jsonrpc": "2.0", "id": id, "result": result}), + Err(error) => json!({"jsonrpc": "2.0", "id": id, "error": error}), + }; + drop(outgoing.send(reply)); + } + }); + + // `.boxed()` rather than `Box::pin`, which infers a future that is not + // spelled `Send` and so does not satisfy `Transport`. + futures::FutureExt::boxed(super::super::rpc::drive( + jp_writes, + jp_reads, + Tap::none(), + handler, + foreground, + )) + }) +} + +#[tokio::test] +async fn the_scripted_adapter_answers_one_request() { + let agent = scripted(|method, _params, _notifier| async move { + assert_eq!(method, agent_method::INITIALIZE); + Ok(json!({"protocolVersion": 1, "agentCapabilities": {}})) + }); + let handler: Handler = Box::new(|_| Box::pin(async { Ok(Value::Null) })); + let foreground: Foreground = Box::new(|peer| { + Box::pin(async move { + peer.request(InitializeRequest { + protocol_version: ProtocolVersion::V1, + client_capabilities: ClientCapabilities::default(), + }) + .await?; + Ok(()) + }) + }); + timeout(Duration::from_secs(5), agent.connect(handler, foreground)) + .await + .unwrap() + .unwrap(); +} + +#[test] +fn an_unspecified_output_limit_does_not_inherit_the_http_fallback() { + let environment = options::environment(&prepared(), CachePolicy::Short); + assert!(!environment.contains_key("CLAUDE_CODE_MAX_OUTPUT_TOKENS")); +} + +#[test] +fn an_explicit_output_limit_is_forwarded() { + let prepared = prepared_with_limit(Some(8192)); + let environment = options::environment(&prepared, CachePolicy::Short); + assert_eq!( + environment + .get("CLAUDE_CODE_MAX_OUTPUT_TOKENS") + .map(String::as_str), + Some("8192") + ); +} + +/// An approval prompt can stay open across a lunch break or a closed laptop, +/// and the adapter's two per-call timers measure wall-clock time regardless of +/// whether JP is scheduled to run. +/// +/// A progress notification cannot stand in for this: a heartbeat only resets +/// the timer if it arrives, and a suspended process sends nothing. +#[test] +fn neither_per_call_timer_can_abort_a_call_waiting_on_the_user() { + const WEEK_MS: i64 = 7 * 24 * 60 * 60 * 1000; + + let environment = options::environment(&prepared(), CachePolicy::Short); + + assert_eq!( + environment + .get("CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT") + .map(String::as_str), + Some("0"), + "the idle check has an off switch, and off is what survives suspension" + ); + + // The wall clock has no off switch, so the ceiling stands in for one: a + // prompt that outlives it has outlived the 32-bit millisecond timer behind + // it. + let ceiling: i64 = environment + .get("MCP_TOOL_TIMEOUT") + .expect("a wall-clock ceiling") + .parse() + .expect("a plain millisecond count, which is all the adapter parses"); + assert!( + ceiling >= WEEK_MS, + "a week is the least a prompt left over a holiday needs, got {ceiling}ms" + ); +} + +#[test] +fn project_directory_is_scoped_by_host_identity_not_worktree_path() { + let mut context = QueryContext { + root: "/work/first".into(), + mcp_endpoint: None, + invocation: Some(InvocationContext { + workspace_id: "otvo8".into(), + conversation_id: "c123456789".into(), + }), + }; + assert_eq!(project_name(&context), "jp-c123456789-otvo8"); + context.root = format!("/work/{}", "long".repeat(100)).into(); + assert_eq!(project_name(&context), "jp-c123456789-otvo8"); + context.invocation.as_mut().unwrap().conversation_id = "c987654321".into(); + assert_eq!(project_name(&context), "jp-c987654321-otvo8"); +} + +#[test] +fn storage_options_do_not_select_a_different_login_directory() { + let mut environment = BTreeMap::new(); + configure_storage_environment(&mut environment, None, "jp-c123-otvo8"); + assert!(environment.is_empty()); + configure_storage_environment(&mut environment, Some("/custom/claude"), "jp-c123-otvo8"); + assert_eq!( + environment, + BTreeMap::from([( + "CLAUDE_CODE_PROJECT_DIR_NAME".into(), + "jp-c123-otvo8".into() + )]) + ); + assert!(!environment.contains_key("CLAUDE_CONFIG_DIR")); +} + +#[test] +fn cache_policy_reaches_the_native_sdk_environment() { + let prepared = prepared(); + for policy in [ + CachePolicy::Off, + CachePolicy::Short, + CachePolicy::Long, + CachePolicy::Custom(Duration::from_secs(1799)), + CachePolicy::Custom(Duration::from_mins(30)), + ] { + let environment = options::environment(&prepared, policy); + let metadata = options::metadata(&prepared, &environment).unwrap(); + let native = &metadata["claudeCode"]["options"]["env"]; + if policy == CachePolicy::Off { + assert_eq!(native["DISABLE_PROMPT_CACHING"], "1"); + } else { + assert!(native.get("DISABLE_PROMPT_CACHING").is_none()); + } + assert!(native.get("CLAUDE_CODE_PROMPT_CACHE_TTL").is_none()); + } +} + +#[test] +fn jp_tool_permissions_always_return_to_the_host() { + let prepared = prepared(); + let metadata = options::metadata(&prepared, &BTreeMap::new()).unwrap(); + assert_eq!( + metadata["claudeCode"]["options"]["settings"]["permissions"], + json!({"ask":["mcp__jp__*"]}) + ); +} + +#[test] +fn a_followup_error_does_not_replace_the_original_failure() { + let state = Mutex::new(State::new("haiku".parse().unwrap(), iter::empty(), false)); + record_failure(&state, StreamError::other("Model unavailable.")); + record_failure(&state, StreamError::other("Execution failed.")); + assert_eq!( + state.into_inner().unwrap().failure.unwrap().message(), + "Model unavailable." + ); +} + +#[tokio::test] +async fn runtime_model_rejection_preserves_its_classification() { + let agent = scripted(|method, params, _notifier| async move { + match method.as_str() { + m if m == agent_method::INITIALIZE => { + Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": true}})) + } + m if m == agent_method::SESSION_LOAD => Ok(json!({})), + m if m == agent_method::SESSION_SET_CONFIG_OPTION => { + assert_eq!(params["configId"], "model"); + Err(RpcError::invalid_params().data("Unknown model on this account.")) + } + other => panic!("unexpected request: {other}"), + } + }); + let mut prepared = prepared(); + prepared.model = "future-model".parse().unwrap(); + let environment = options::environment(&prepared, CachePolicy::Short); + let (sender, mut receiver) = mpsc::channel(16); + let error = timeout( + Duration::from_secs(5), + drive( + prepared, + QueryContext { + root: "/work/project".into(), + mcp_endpoint: None, + invocation: None, + }, + vec![], + environment, + NativeArtifact { + session: Some(Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap()), + path: None, + }, + agent, + sender, + ), + ) + .await + .unwrap() + .unwrap_err(); + let Error::Stream(error) = error else { + panic!("expected classified failure") + }; + assert!(!error.is_retryable()); + let Some(Error::ModelSelection { model, source }) = + error.source().unwrap().downcast_ref::() + else { + panic!("expected model selection failure") + }; + assert_eq!(model.as_ref(), "future-model"); + assert_eq!( + serde_json::to_value(source).unwrap()["data"], + "Unknown model on this account." + ); + assert!(receiver.recv().await.is_none()); +} + +#[tokio::test(start_paused = true)] +async fn buffered_tool_arguments_remain_live_without_dispatching_a_tool() { + timeout(Duration::from_mins(2), async { + let LivenessFixture { + driver, + mut events, + finish, + } = liveness_fixture(true); + while events.recv().await.unwrap().unwrap() != Event::flush(0) {} + assert_eq!( + events.recv().await.unwrap().unwrap(), + Event::ToolCallPending { + id: "call-args".into(), + name: "lookup".into() + } + ); + // Hold argument generation open longer than the normal 60-second idle limit. + for _ in 0..14 { + advance(Duration::from_secs(5)).await; + assert_eq!( + timeout(Duration::from_secs(6), events.recv()) + .await + .unwrap() + .unwrap() + .unwrap(), + Event::KeepAlive + ); + } + finish.notify_one(); + let mut remaining = vec![]; + while let Some(event) = events.recv().await { + let event = event.unwrap(); + if event != Event::KeepAlive { + remaining.push(event); + } + } + assert_eq!(remaining, vec![ + Event::ToolCallPendingEnd { + id: "call-args".into() + }, + Event::Finished(FinishReason::Completed) + ]); + driver.await.unwrap().unwrap(); + }) + .await + .unwrap(); +} + +#[tokio::test(start_paused = true)] +async fn a_quiet_agent_outside_tool_work_does_not_get_timer_activity() { + timeout(Duration::from_secs(90), async { + let LivenessFixture { + driver, + mut events, + finish, + } = liveness_fixture(false); + while events.recv().await.unwrap().unwrap() != Event::flush(0) {} + assert!( + timeout(Duration::from_secs(65), events.recv()) + .await + .is_err() + ); + finish.notify_one(); + let mut remaining = vec![]; + while let Some(event) = events.recv().await { + let event = event.unwrap(); + if event != Event::KeepAlive { + remaining.push(event); + } + } + assert_eq!(remaining, vec![Event::Finished(FinishReason::Completed)]); + driver.await.unwrap().unwrap(); + }) + .await + .unwrap(); +} + +struct LivenessFixture { + driver: JoinHandle>, + events: mpsc::Receiver>, + finish: Arc, +} + +fn liveness_fixture(arguments: bool) -> LivenessFixture { + let finish = Arc::new(Notify::new()); + let release = finish.clone(); + let agent = scripted(move |method, params, notifier| { + let release = release.clone(); + async move { + match method.as_str() { + m if m == agent_method::INITIALIZE => Ok( + json!({"protocolVersion": 1, "agentCapabilities": {"mcpCapabilities": {"http": true}}}), + ), + m if m == agent_method::SESSION_NEW => { + notifier.auth("Claude Max"); + Ok(json!({"sessionId": "11111111-1111-4111-8111-111111111111"})) + } + m if m == agent_method::SESSION_SET_CONFIG_OPTION => Ok(json!({ + "configOptions": [{ + "id": params["configId"], + "name": "Setting", + "type": "select", + "currentValue": params["value"], + "options": [], + }], + })), + m if m == agent_method::SESSION_PROMPT => { + notifier.sdk( + json!({"type": "system", "subtype": "init", "tools": ["mcp__jp__lookup"]}), + ); + notifier.sdk(json!({"type": "stream_event", "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": "Rewrite."}}})); + notifier.sdk(json!({"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}})); + if arguments { + notifier.sdk(json!({"type": "stream_event", "event": {"type": "content_block_start", "index": 1, "content_block": {"type": "tool_use", "id": "call-args", "name": "mcp__jp__lookup", "input": {}}}})); + } + release.notified().await; + if arguments { + notifier.sdk(json!({"type": "stream_event", "event": {"type": "content_block_stop", "index": 1}})); + } + notifier + .sdk(json!({"type": "result", "subtype": "success", "is_error": false})); + Ok(json!({"stopReason": "end_turn"})) + } + other => panic!("unexpected request: {other}"), + } + } + }); + let prepared = prepared(); + let environment = options::environment(&prepared, CachePolicy::Short); + let (sender, receiver) = mpsc::channel(16); + let driver = tokio::spawn(drive( + prepared, + QueryContext { + root: "/work/project".into(), + mcp_endpoint: Some("http://127.0.0.1:1/mcp".parse().unwrap()), + invocation: None, + }, + vec!["lookup".into()], + environment, + NativeArtifact { + session: None, + path: None, + }, + agent, + sender, + )); + LivenessFixture { + driver, + events: receiver, + finish, + } +} + +#[tokio::test] +async fn real_protocol_driver_loads_history_and_emits_only_live_output() { + let agent = scripted(|method, params, notifier| async move { + match method.as_str() { + m if m == agent_method::INITIALIZE => { + assert_eq!(params["protocolVersion"], 1); + Ok(json!({ + "protocolVersion": 1, + "agentCapabilities": {"loadSession": true, "mcpCapabilities": {"http": true}}, + })) + } + m if m == agent_method::SESSION_LOAD => { + assert_eq!(params["sessionId"], "11111111-1111-4111-8111-111111111111"); + assert_eq!(params["cwd"], "/work/project"); + insta::assert_json_snapshot!("session_options", params["_meta"]); + notifier.auth("Claude Max"); + // Replay of the loaded transcript, which must not reach the + // caller as live output. + notifier.sdk(json!({"type": "stream_event", "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": "REPLAY MUST NOT APPEAR"}}})); + Ok(json!({})) + } + m if m == agent_method::SESSION_SET_CONFIG_OPTION => { + let current = if params["configId"] == "model" { + assert_eq!(params["value"], "claude-opus-5"); + json!("resolved-fixture-model") + } else { + params["value"].clone() + }; + Ok(json!({ + "configOptions": [{ + "id": params["configId"], + "name": "Setting", + "type": "select", + "currentValue": current, + "options": [], + }], + })) + } + m if m == agent_method::SESSION_PROMPT => { + assert_eq!( + params["prompt"], + json!([{"type": "text", "text": "Current request."}]) + ); + notifier.sdk(json!({"type": "system", "subtype": "init", "tools": []})); + notifier.sdk(json!({"type": "stream_event", "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}})); + notifier.sdk(json!({"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "CURRENT"}}})); + notifier.sdk(json!({"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}})); + notifier.sdk(json!({"type": "assistant", "message": {"id": "msg-traced", "model": "resolved-fixture-model", "content": [{"type": "text", "text": "CURRENT"}], "usage": {"input_tokens": 2, "output_tokens": 7, "cache_read_input_tokens": 500}}})); + notifier.sdk(json!({"type": "result", "subtype": "success", "is_error": false})); + Ok(json!({"stopReason": "end_turn"})) + } + other => panic!("unexpected request: {other}"), + } + }); + let prepared = prepared(); + let environment = options::environment(&prepared, CachePolicy::Short); + let (sender, mut receiver) = mpsc::channel(16); + let artifact = NativeArtifact { + session: Some(Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap()), + path: None, + }; + let capture = UsageCapture::default(); + let subscriber = registry().with(capture.clone()); + timeout( + Duration::from_secs(5), + drive( + prepared, + QueryContext { + root: "/work/project".into(), + mcp_endpoint: None, + invocation: None, + }, + vec![], + environment, + artifact, + agent, + sender, + ) + .with_subscriber(subscriber), + ) + .await + .unwrap() + .unwrap(); + let mut events = vec![]; + while let Some(event) = receiver.recv().await { + events.push(event.unwrap()); + } + assert_eq!( + capture.snapshot().unwrap(), + json!({"native_session_id":"11111111-1111-4111-8111-111111111111","requests":{"msg-traced":{"model":"resolved-fixture-model","input_tokens":2,"output_tokens":7,"cache_read_input_tokens":500}}}) + ); + // Replay emits nothing; live SDK observations without content are liveness. + assert_eq!(events, vec![ + Event::KeepAlive, + Event::KeepAlive, + Event::Part { + index: 0, + part: EventPart::Message("CURRENT".into()), + metadata: Map::new() + }, + Event::flush(0), + Event::KeepAlive, + Event::KeepAlive, + Event::Finished(FinishReason::Completed) + ]); +} diff --git a/crates/jp_llm/src/provider/anthropic/acp/usage.rs b/crates/jp_llm/src/provider/anthropic/acp/usage.rs new file mode 100644 index 000000000..e67446747 --- /dev/null +++ b/crates/jp_llm/src/provider/anthropic/acp/usage.rs @@ -0,0 +1,110 @@ +//! Request and runtime usage snapshots for the Claude ACP flow. +//! +//! Repeated assistant messages update the entry identified by their message ID. +//! Runtime totals include work outside the main request stream and are retained +//! separately, never added to those entries. + +use std::collections::BTreeMap; + +use async_anthropic::types::{CreateMessagesResponse, Usage}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +#[derive(Debug, Default)] +pub(super) struct UsageLedger { + requests: BTreeMap, + runtime: Option, +} + +#[derive(Debug, Serialize)] +struct RequestUsage { + model: String, + #[serde(flatten)] + usage: Usage, +} + +/// Native SDK aggregates, not increments to the main request counters. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub(super) struct RuntimeUsage { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage: Option, + #[serde(default)] + pub model_usage: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub estimated_cost_usd: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct ModelUsage { + #[serde(default, skip_serializing_if = "Option::is_none")] + input_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + output_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + cache_read_input_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + cache_creation_input_tokens: Option, + #[serde(default, rename = "costUSD", skip_serializing_if = "Option::is_none")] + cost_usd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + context_window: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + max_output_tokens: Option, +} + +impl UsageLedger { + pub(super) fn observe(&mut self, message: &CreateMessagesResponse) { + let (Some(id), Some(model), Some(usage)) = (&message.id, &message.model, &message.usage) + else { + return; + }; + self.observe_usage(id, model, usage); + } + + pub(super) fn observe_usage(&mut self, id: &str, model: &str, usage: &Usage) { + let entry = self + .requests + .entry(id.to_owned()) + .or_insert_with(|| RequestUsage { + model: model.to_owned(), + usage: usage.clone(), + }); + entry.usage.input_tokens = usage.input_tokens.or(entry.usage.input_tokens); + // A complete assistant block can repeat the initial usage after the + // terminal message_delta has reported the final generated count. + entry.usage.output_tokens = entry.usage.output_tokens.max(usage.output_tokens); + entry.usage.cache_creation_input_tokens = usage + .cache_creation_input_tokens + .or(entry.usage.cache_creation_input_tokens); + entry.usage.cache_read_input_tokens = usage + .cache_read_input_tokens + .or(entry.usage.cache_read_input_tokens); + if let Some(creation) = &usage.cache_creation { + entry.usage.cache_creation = Some(creation.clone()); + } + } + + pub(super) fn set_runtime(&mut self, runtime: RuntimeUsage) { + if runtime.usage.is_some() + || !runtime.model_usage.is_empty() + || runtime.estimated_cost_usd.is_some() + { + self.runtime = Some(runtime); + } + } + + /// A cumulative snapshot; consumers replace earlier snapshots for this + /// native session instead of summing repeated observations. + pub(super) fn snapshot(&self, session: &str) -> Value { + let mut value = json!({"native_session_id":session,"requests":self.requests}); + if let Some(runtime) = &self.runtime { + value["runtime"] = json!(runtime); + } + value + } +} + +#[cfg(test)] +#[path = "usage_tests.rs"] +mod tests; diff --git a/crates/jp_llm/src/provider/anthropic/acp/usage_tests.rs b/crates/jp_llm/src/provider/anthropic/acp/usage_tests.rs new file mode 100644 index 000000000..46f82bcbb --- /dev/null +++ b/crates/jp_llm/src/provider/anthropic/acp/usage_tests.rs @@ -0,0 +1,55 @@ +use async_anthropic::types::CreateMessagesResponse; +use serde_json::json; + +use super::*; + +#[test] +fn repeated_message_ids_replace_snapshots_instead_of_adding_tokens() { + let first: CreateMessagesResponse = serde_json::from_value(json!({"id":"msg-fixed","model":"claude-opus-5","usage":{"input_tokens":12,"output_tokens":2,"cache_creation_input_tokens":100,"cache_read_input_tokens":200}})).unwrap(); + let later: CreateMessagesResponse = serde_json::from_value( + json!({"id":"msg-fixed","model":"claude-opus-5","usage":{"output_tokens":7}}), + ) + .unwrap(); + let mut ledger = UsageLedger::default(); + ledger.observe(&first); + ledger.observe(&later); + assert_eq!( + ledger.snapshot("session-fixed"), + json!({ + "native_session_id":"session-fixed", + "requests":{"msg-fixed":{"model":"claude-opus-5","input_tokens":12,"output_tokens":7,"cache_creation_input_tokens":100,"cache_read_input_tokens":200}} + }) + ); +} + +#[test] +fn runtime_totals_are_separate_from_main_request_usage() { + let message: CreateMessagesResponse = serde_json::from_value(json!({"id":"msg-main","model":"claude-opus-5","usage":{"input_tokens":2,"output_tokens":4,"cache_creation_input_tokens":0,"cache_read_input_tokens":500}})).unwrap(); + let mut ledger = UsageLedger::default(); + ledger.observe(&message); + ledger.set_runtime(serde_json::from_value(json!({ + "usage":{"input_tokens":9,"output_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":500}, + "model_usage":{"claude-opus-5":{"inputTokens":2,"outputTokens":4},"claude-haiku-4-5":{"inputTokens":7,"outputTokens":6}}, + "estimated_cost_usd":0.02 + })).unwrap()); + let snapshot = ledger.snapshot("session-fixed"); + assert_eq!(snapshot["requests"]["msg-main"]["input_tokens"], 2); + assert_eq!(snapshot["runtime"]["usage"]["input_tokens"], 9); + assert_eq!( + snapshot["runtime"]["model_usage"]["claude-haiku-4-5"]["outputTokens"], + 6 + ); + assert_eq!(snapshot["runtime"]["estimated_cost_usd"], 0.02); +} + +#[test] +fn missing_usage_is_not_reported_as_zero() { + let message: CreateMessagesResponse = + serde_json::from_value(json!({"id":"msg-fixed","model":"claude-opus-5"})).unwrap(); + let mut ledger = UsageLedger::default(); + ledger.observe(&message); + assert_eq!( + ledger.snapshot("session-fixed"), + json!({"native_session_id":"session-fixed","requests":{}}) + ); +} diff --git a/crates/jp_llm/src/provider/anthropic/acp/workflow_tests.rs b/crates/jp_llm/src/provider/anthropic/acp/workflow_tests.rs new file mode 100644 index 000000000..a738366fb --- /dev/null +++ b/crates/jp_llm/src/provider/anthropic/acp/workflow_tests.rs @@ -0,0 +1,220 @@ +use datetime_literal::datetime; +use jp_config::{AppConfig, PartialAppConfig}; +use jp_conversation::{ + Compaction, ConversationStream, SummaryPolicy, + event::{ + ChatRequest, ChatResponse, ConversationEvent, EventKind, ToolCallRequest, ToolCallResponse, + TurnStart, + }, + thread::ThreadBuilder, +}; +use serde_json::{Map, json}; + +use super::transcript::PreparedRequest; +use crate::query::ChatQuery; + +fn conversation() -> ChatQuery { + let timestamp = datetime!(2026-09-11 12:00:00 Z); + let mut stream = + ConversationStream::new(AppConfig::new_test().into()).with_created_at(timestamp); + stream.extend([ + ConversationEvent::new(TurnStart, timestamp), + ConversationEvent::new(ChatRequest::from("Lookup."), timestamp), + ConversationEvent::new( + ToolCallRequest::new("call-fixed".into(), "lookup".into(), Map::new()), + timestamp, + ), + ConversationEvent::new( + ToolCallResponse { + id: "call-fixed".into(), + result: Ok("OAK".into()), + }, + timestamp, + ), + ConversationEvent::new(ChatResponse::message("Found OAK."), timestamp), + ]); + stream.add_config_delta( + serde_json::from_value::( + json!({"assistant":{"model":{"id":"openai/gpt-6-astra"}}}), + ) + .unwrap(), + ); + stream.extend([ + ConversationEvent::new(TurnStart, timestamp), + ConversationEvent::new(ChatRequest::from("Check."), timestamp), + ConversationEvent::new(ChatResponse::reasoning("Foreign reasoning."), timestamp), + ConversationEvent::new(ChatResponse::message("Confirmed OAK."), timestamp), + ]); + stream.add_config_delta( + serde_json::from_value::( + json!({"assistant":{"model":{"id":"anthropic/claude-opus-5"}}}), + ) + .unwrap(), + ); + stream.extend([ + ConversationEvent::new(TurnStart, timestamp), + ConversationEvent::new(ChatRequest::from("Continue."), timestamp), + ]); + ThreadBuilder::new() + .with_system_prompt("Original instructions.") + .with_events(stream) + .build() + .unwrap() + .into() +} + +#[test] +fn returning_to_anthropic_retains_the_intervening_openai_turn() { + let model = super::model_details(&"claude-opus-5".parse().unwrap()); + let prepared = PreparedRequest::new(&model, conversation()).unwrap(); + assert_eq!(prepared.prompt, "Continue."); + assert_eq!( + serde_json::to_value(prepared.history).unwrap(), + json!([ + {"role":"user","content":[{"type":"text","text":"Lookup."}]}, + {"role":"assistant","content":[{"type":"tool_use","id":"call-fixed","name":"mcp__jp__lookup","input":{}}]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":"call-fixed","content":"OAK","is_error":false}]}, + {"role":"assistant","content":[{"type":"text","text":"Found OAK."}]}, + {"role":"user","content":[{"type":"text","text":"Check."}]}, + {"role":"assistant","content":[{"type":"text","text":"\nForeign reasoning.\n\n\n"},{"type":"text","text":"Confirmed OAK."}]} + ]) + ); +} + +#[test] +fn selected_turn_fork_excludes_unselected_history() { + let mut query = conversation(); + query.thread.events.retain_turns(|index| index != 1); + let model = super::model_details(&"claude-opus-5".parse().unwrap()); + let prepared = PreparedRequest::new(&model, query).unwrap(); + assert_eq!(prepared.prompt, "Continue."); + assert_eq!( + serde_json::to_value(prepared.history).unwrap(), + json!([ + {"role":"user","content":[{"type":"text","text":"Lookup."}]}, + {"role":"assistant","content":[{"type":"tool_use","id":"call-fixed","name":"mcp__jp__lookup","input":{}}]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":"call-fixed","content":"OAK","is_error":false}]}, + {"role":"assistant","content":[{"type":"text","text":"Found OAK."}]} + ]) + ); +} + +#[test] +fn replay_uses_the_replacement_request() { + let mut query = conversation(); + query.thread.events.retain_turns(|index| index < 2); + query.thread.events.extend([ + ConversationEvent::new(TurnStart, datetime!(2026-09-11 12:01:00 Z)), + ConversationEvent::new( + ChatRequest::from("Revisit instead."), + datetime!(2026-09-11 12:01:00 Z), + ), + ]); + let model = super::model_details(&"claude-opus-5".parse().unwrap()); + let prepared = PreparedRequest::new(&model, query).unwrap(); + assert_eq!(prepared.prompt, "Revisit instead."); + assert_eq!(prepared.history.len(), 6); + assert_eq!( + serde_json::to_value(&prepared.history[5]).unwrap(), + json!({"role":"assistant","content":[{"type":"text","text":"\nForeign reasoning.\n\n\n"},{"type":"text","text":"Confirmed OAK."}]}) + ); +} + +#[test] +fn compacted_view_is_encoded_without_mutating_raw_history() { + let mut query = conversation(); + let mut compaction = Compaction::new(0, 1).with_summary(SummaryPolicy::authored( + "Lookup and verification established OAK.", + )); + compaction.timestamp = datetime!(2026-09-11 12:01:00 Z); + query.thread.events.add_compaction(compaction); + let before = query + .thread + .events + .iter() + .map(|event| event.event.clone()) + .collect::>(); + let model = super::model_details(&"claude-opus-5".parse().unwrap()); + let prepared = PreparedRequest::new(&model, query.clone()).unwrap(); + assert_eq!(prepared.prompt, "Continue."); + assert_eq!(prepared.history.len(), 2); + insta::assert_json_snapshot!("compacted_native_history", prepared.history); + assert_eq!( + query + .thread + .events + .iter() + .map(|event| event.event.clone()) + .collect::>(), + before + ); +} + +#[test] +fn tool_result_continuation_does_not_repeat_the_previous_request() { + let mut query = conversation(); + query.thread.events.retain_turns(|index| index == 0); + query + .thread + .events + .retain(|event| !matches!(event.kind, EventKind::ChatResponse(_))); + let model = super::model_details(&"claude-opus-5".parse().unwrap()); + let prepared = PreparedRequest::new(&model, query).unwrap(); + assert_eq!( + prepared.prompt, + "Continue your response exactly from where you left off. Do not repeat content you \ + already produced." + ); + assert_eq!(prepared.history.len(), 3); + assert_eq!( + serde_json::to_value(&prepared.history[2]).unwrap(), + json!({"role":"user","content":[{"type":"tool_result","tool_use_id":"call-fixed","content":"OAK","is_error":false}]}) + ); +} + +#[test] +fn large_historical_tool_results_remain_complete() { + let mut query = conversation(); + let content = "0123456789abcdef".repeat(15_000) + "END-OF-RESULT"; + for event in query.thread.events.iter_mut() { + if let EventKind::ToolCallResponse(response) = &mut event.event.kind { + response.result = Ok(content.clone()); + } + } + let model = super::model_details(&"claude-opus-5".parse().unwrap()); + let prepared = PreparedRequest::new(&model, query).unwrap(); + let message = serde_json::to_value(&prepared.history[2]).unwrap(); + // This is a preservation assertion, not a generated-output expectation. + assert_eq!( + message["content"][0]["content"].as_str(), + Some(content.as_str()) + ); + assert_eq!(prepared.prompt, "Continue."); +} + +#[test] +fn changed_instructions_schema_and_tool_result_reach_the_next_request() { + let mut query = conversation(); + query.thread.system_prompt = Some("Replacement instructions.".into()); + let schema = json!({"type":"object","properties":{"answer":{"type":"string"}},"required":["answer"],"additionalProperties":false}).as_object().unwrap().clone(); + for event in query.thread.events.iter_mut() { + if let EventKind::ToolCallResponse(response) = &mut event.event.kind { + response.result = Ok("CEDAR".into()); + } + } + if let EventKind::ChatRequest(request) = + &mut query.thread.events.iter_mut().last().unwrap().event.kind + { + request.schema = Some(schema.clone()); + } else { + panic!("expected the pending request") + } + let model = super::model_details(&"claude-opus-5".parse().unwrap()); + let prepared = PreparedRequest::new(&model, query).unwrap(); + assert_eq!(prepared.system_prompt, "Replacement instructions."); + assert_eq!(prepared.schema, Some(schema)); + assert_eq!( + serde_json::to_value(&prepared.history[2]).unwrap(), + json!({"role":"user","content":[{"type":"tool_result","tool_use_id":"call-fixed","content":"CEDAR","is_error":false}]}) + ); +} diff --git a/crates/jp_llm/src/provider/anthropic/acp_tests.rs b/crates/jp_llm/src/provider/anthropic/acp_tests.rs new file mode 100644 index 000000000..6a5697de6 --- /dev/null +++ b/crates/jp_llm/src/provider/anthropic/acp_tests.rs @@ -0,0 +1,160 @@ +use assert_matches::assert_matches; +use jp_config::{ + AppConfig, + providers::llm::{AuthEntry, anthropic::SubscriptionFlow}, +}; +use test_log::test; + +use super::*; +use crate::{ + Error as LlmError, + provider::anthropic::{ + Anthropic, + resolve::{self, Route}, + }, +}; + +#[test] +fn qualified_runtime_pair() { + qualify_versions(b"0.76.0\n", b"2.1.257 (Claude Code)\n").unwrap(); + assert_matches!( + qualify_versions(b"0.77.0", b"2.1.257"), + Err(Error::UnsupportedVersion { + check: Check::AdapterVersion, + .. + }) + ); + assert_matches!( + qualify_versions(b"0.76.0", b"2.1.258"), + Err(Error::UnsupportedVersion { + check: Check::ClaudeVersion, + .. + }) + ); + assert_matches!( + qualify_versions(b"0.76.0", b"2.1.257-extra"), + Err(Error::UnsupportedVersion { .. }) + ); +} + +#[test] +fn subscription_status_is_fail_closed() { + validate_auth(br#"{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty","subscriptionType":"max","email":"ignored"}"#).unwrap(); + validate_auth(br#"{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty","subscriptionType":"Claude Pro"}"#).unwrap(); + assert_matches!(validate_auth(br#"{"loggedIn":true,"authMethod":"api_key","apiProvider":"firstParty","subscriptionType":"max"}"#), Err(Error::SubscriptionRequired)); + assert_matches!(validate_auth(br#"{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"bedrock","subscriptionType":"max"}"#), Err(Error::SubscriptionRequired)); + assert_matches!( + validate_auth(br#"{"loggedIn":false}"#), + Err(Error::SubscriptionRequired) + ); + assert_matches!( + validate_auth(br#"{"loggedIn":true}"#), + Err(Error::SubscriptionRequired) + ); + assert_matches!(validate_auth(br#"{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty","subscriptionType":"unknown"}"#), Err(Error::SubscriptionRequired)); + assert_matches!(validate_auth(br#"{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty","subscriptionType":"max","apiKeySource":"apiKeyHelper"}"#), Err(Error::SubscriptionRequired)); + assert_matches!(validate_auth(b"not json"), Err(Error::AuthStatus(_))); +} + +#[test] +fn model_names_are_not_restricted_to_the_probe_model() { + let model = model_details(&"claude-opus-5".parse().unwrap()); + assert_eq!(model.id.to_string(), "anthropic/claude-opus-5"); + assert_eq!(model.subscription, Some(true)); + assert_eq!(model.context_window, None); + assert_eq!(model.prefill, Some(false)); + for name in [ + "claude-haiku-4-5", + "claude-sonnet-4-6", + "haiku", + "future-model", + ] { + let name = name.parse().unwrap(); + let model = model_details(&name); + assert_eq!(model.id.name, name); + } +} + +#[test(tokio::test)] +async fn subscription_construction_uses_no_store_or_external_runtime() { + let mut config = AppConfig::new_test().providers.llm.anthropic; + config.auth = vec![AuthEntry::Subscription(None)]; + let provider = Anthropic::new(&config).unwrap(); + assert!(!resolve::needs_store(&config)); + assert_matches!( + provider.resolve("claude-opus-5").await.unwrap().route, + Route::Acp + ); +} + +#[test] +fn api_construction_ignores_subscription_flow() { + let mut config = AppConfig::new_test().providers.llm.anthropic; + config.api_key_env = "JP_TEST_PHASE1_MISSING_API_KEY".into(); + for flow in [SubscriptionFlow::Acp, SubscriptionFlow::Direct] { + config.subscription_flow = flow; + assert!(!resolve::needs_store(&config)); + assert_matches!(Anthropic::new(&config), Err(LlmError::MissingEnv(variable)) if variable == "JP_TEST_PHASE1_MISSING_API_KEY"); + } +} + +#[test] +fn child_environment_filter_preserves_native_login_location() { + assert!(removes_variable("ANTHROPIC_API_KEY")); + assert!(removes_variable("anthropic_api_key")); + assert!(removes_variable("FORCE_PROMPT_CACHING_5M")); + assert!(removes_variable("ENABLE_PROMPT_CACHING_1H")); + assert!(removes_variable("ANTHROPIC_BASE_URL")); + assert!(removes_variable("CLAUDE_CODE_USE_BEDROCK")); + assert!(removes_variable("CLAUDE_CODE_OAUTH_TOKEN")); + assert!(removes_variable("DISABLE_PROMPT_CACHING_OPUS")); + assert!(removes_variable("DISABLE_PROMPT_CACHING")); + assert!(removes_variable("CLAUDE_CODE_PROMPT_CACHE_TTL")); + assert!(!removes_variable("HOME")); + assert!(!removes_variable("CLAUDE_CONFIG_DIR")); + assert!(!removes_variable("PATH")); +} + +#[cfg(unix)] +#[test(tokio::test)] +async fn inspection_output_is_bounded() { + let mut command = Command::new("sh"); + command.args(["-c", "printf '%65537s' ''"]); + assert_matches!( + read_output(command, Check::AdapterVersion).await, + Err(Error::OutputLimit { + check: Check::AdapterVersion + }) + ); +} + +#[test(tokio::test)] +async fn missing_runtime_has_setup_guidance() { + let command = Command::new("jp-test-nonexistent-claude-agent-acp"); + let error = read_output(command, Check::AdapterVersion) + .await + .unwrap_err(); + assert_matches!(&error, Error::Io { source, .. } if source.kind() == io::ErrorKind::NotFound); + assert_eq!( + error.to_string(), + "Claude ACP adapter-version check failed; install \ + @agentclientprotocol/claude-agent-acp@0.76.0 with Node.js 22+ and optional dependencies \ + enabled" + ); +} + +#[cfg(unix)] +#[test(tokio::test)] +async fn command_failure_is_typed_and_does_not_include_output() { + let mut command = Command::new("sh"); + command.args(["-c", "printf private-diagnostic; exit 7"]); + let error = read_output(command, Check::Authentication) + .await + .unwrap_err(); + assert_matches!(error, Error::CommandFailed { status, .. } if status.code() == Some(7)); + assert_eq!( + error.to_string(), + "Claude ACP authentication check exited with exit status: 7; check `claude-agent-acp \ + --cli auth status --json`" + ); +} diff --git a/crates/jp_llm/src/provider/anthropic/http.rs b/crates/jp_llm/src/provider/anthropic/http.rs new file mode 100644 index 000000000..674bba8c4 --- /dev/null +++ b/crates/jp_llm/src/provider/anthropic/http.rs @@ -0,0 +1,65 @@ +//! HTTP client construction for API-key and direct subscription requests. + +use std::sync::{Arc, Mutex}; + +use async_anthropic::{Client, bearer, errors::AnthropicError}; +use jp_config::providers::llm::anthropic::AnthropicConfig; +use tracing::debug; + +use crate::{ + credential::Credential, + error::{Error, Result}, +}; + +/// Retains a connection pool while the selected credential is unchanged. +#[derive(Debug, Clone, Default)] +pub(super) struct Clients { + cached: Arc>>, +} + +impl Clients { + /// Build the client and report whether requests require bearer shaping. + pub(super) fn get( + &self, + config: &AnthropicConfig, + credential: &Credential, + ) -> Result<(Client, bool)> { + let mut cache = self.cached.lock().expect("poisoned"); + if let Some((cached, client, bearer)) = cache.as_ref() + && cached == credential + { + return Ok((client.clone(), *bearer)); + } + let mut builder = Client::builder(); + builder + .base_url(config.base_url.clone()) + .version("2023-06-01"); + let bearer = match credential { + Credential::ApiKey(key) => { + builder.api_key(key.clone()); + false + } + Credential::Bearer(token) => { + builder.auth_token(token.clone()); + true + } + }; + if !config.beta_headers.is_empty() { + builder.beta(config.beta_headers.join(",")); + } + debug!( + bearer, + betas = %if bearer { + bearer::merge_betas((!config.beta_headers.is_empty()).then(|| config.beta_headers.join(",")).as_deref()) + } else { + config.beta_headers.join(",") + }, + "Constructing Anthropic client." + ); + let client = builder + .build() + .map_err(|e| Error::Anthropic(AnthropicError::Unknown(e.to_string())))?; + *cache = Some((credential.clone(), client.clone(), bearer)); + Ok((client, bearer)) + } +} diff --git a/crates/jp_llm/src/provider/anthropic/resolve.rs b/crates/jp_llm/src/provider/anthropic/resolve.rs index ebf394a27..744abbdf8 100644 --- a/crates/jp_llm/src/provider/anthropic/resolve.rs +++ b/crates/jp_llm/src/provider/anthropic/resolve.rs @@ -28,7 +28,7 @@ use std::{ use async_anthropic::errors::{UnifiedRateLimit, WindowUtilization}; use chrono::{DateTime, Utc}; use jp_config::{ - providers::llm::anthropic::{AnthropicConfig, AuthEntry}, + providers::llm::anthropic::{AnthropicConfig, AuthEntry, SubscriptionFlow}, types::api_key_env::ApiKeyEnv, }; use jp_credentials::{ @@ -37,11 +37,15 @@ use jp_credentials::{ }; use tracing::{debug, warn}; -use crate::{credential::Credential, error::StreamError, provider::anthropic::oauth}; +use super::{acp::Error as AcpError, oauth}; +use crate::{credential::Credential, error::StreamError}; /// Errors from walking the credential chain. #[derive(Debug, thiserror::Error)] pub enum ResolveError { + /// ACP selection failed before a request could be sent. + #[error(transparent)] + Acp(#[from] AcpError), #[error(transparent)] Store(#[from] StoreError), @@ -139,6 +143,8 @@ pub enum ResolveError { /// What a walk of the chain landed on. #[derive(Debug)] enum Landing { + /// Use Claude Code's own authentication without reading stored tokens. + Acp, /// A credential that can be sent as-is. Ready(Credential), @@ -168,12 +174,11 @@ pub(super) struct Selected { pub generation: Option, } -/// A resolved chain attempt: the credential to send with, the entry that -/// produced it, and notices for entries skipped on the way there. +/// A resolved request route and the chain entry that selected it. #[derive(Debug)] pub(super) struct Attempt { - /// The credential the request authenticates with. - pub credential: Credential, + /// The request implementation, carrying a credential only for HTTP. + pub route: Route, /// Which chain entry produced the credential. /// @@ -207,7 +212,7 @@ impl Attempt { /// stored profile to record an outcome against. pub(super) fn injected(credential: Credential) -> Self { Self { - credential, + route: Route::Http(credential), selected: None, notices: vec![], switch: None, @@ -246,6 +251,24 @@ impl SeenNotices { } } +/// The selected request implementation and its authentication material. +#[derive(Debug)] +pub(super) enum Route { + /// An API key or an explicitly selected direct subscription token. + Http(Credential), + /// Authentication is owned by Claude Code, not by JP's token store. + Acp, +} + +/// Whether resolving this chain requires consulting JP's credential store. +pub(super) fn needs_store(config: &AnthropicConfig) -> bool { + config.auth.iter().any(|entry| match entry { + AuthEntry::ApiKey(_) => false, + AuthEntry::Subscription(_) => config.subscription_flow == SubscriptionFlow::Direct, + AuthEntry::Named(_) => true, + }) +} + /// Check that some entry of the chain could resolve, without any network use. /// /// An access token that has expired counts as usable: [`resolve`] refreshes it @@ -300,6 +323,17 @@ async fn resolve_skipping( walk_chain(config, snapshot.as_ref(), model, now, &tried)?; let (profile, refresh_token) = match landing { + Landing::Acp => { + debug!(entry = %selected.entry, model, "Resolved ACP subscription route."); + + return Ok(Attempt { + route: Route::Acp, + selected: Some(selected), + notices, + switch: None, + tried, + }); + } Landing::Ready(credential) => { debug!( entry = %selected.entry, @@ -310,7 +344,7 @@ async fn resolve_skipping( ); return Ok(Attempt { - credential, + route: Route::Http(credential), selected: Some(selected), notices, switch: None, @@ -842,6 +876,32 @@ fn walk_chain( } AuthEntry::Subscription(name) => { + // Claude Code owns this login, so there is no stored profile + // to look up, cool down, or refresh. + if config.subscription_flow == SubscriptionFlow::Acp { + if let Some(name) = name { + return Err(AcpError::NamedSubscription { name: name.clone() }.into()); + } + + if tried.contains(entry) { + skip( + &mut notices, + &mut reasons, + format!("{entry}: already tried for this request"), + ); + continue; + } + + return Ok(( + Landing::Acp, + Selected { + entry: entry.clone(), + generation: None, + }, + notices, + )); + } + match walk_profile(store, name.as_deref(), entry, model, now, tried)? { ProfileStep::Landed(landing, selected) => { return Ok((landing, selected, notices)); diff --git a/crates/jp_llm/src/provider/anthropic/resolve_tests.rs b/crates/jp_llm/src/provider/anthropic/resolve_tests.rs index aca80fd0b..7ff624b22 100644 --- a/crates/jp_llm/src/provider/anthropic/resolve_tests.rs +++ b/crates/jp_llm/src/provider/anthropic/resolve_tests.rs @@ -22,6 +22,7 @@ const MODEL: &str = "claude-opus-4-6"; fn anthropic_config(auth: &[&str], api_key_env: &str) -> AnthropicConfig { let mut config = jp_config::AppConfig::new_test().providers.llm.anthropic; + config.subscription_flow = SubscriptionFlow::Direct; config.auth = auth.iter().map(|s| s.parse().unwrap()).collect(); config.api_key_env = api_key_env.to_owned().into(); config @@ -78,7 +79,7 @@ fn selected_profile(name: &str) -> Selected { /// An attempt that resolved to `selected`, for driving [`advance`]. fn attempt_on(selected: Selected) -> Attempt { Attempt { - credential: Credential::Bearer("resolved".to_owned()), + route: Route::Http(Credential::Bearer("resolved".to_owned())), selected: Some(selected), notices: vec![], switch: None, @@ -95,12 +96,66 @@ fn entry_of(attempt: &Attempt) -> Option { fn ready(landing: Landing) -> Credential { match landing { Landing::Ready(credential) => credential, + Landing::Acp => panic!("expected an explicit direct subscription flow"), Landing::Stale { profile, .. } => { panic!("subscription:{profile} unexpectedly needs a refresh") } } } +#[test] +fn acp_subscription_does_not_require_stored_tokens() { + let mut config = anthropic_config(&["subscription"], UNSET_ENV_VAR); + config.subscription_flow = SubscriptionFlow::Acp; + let (landing, selected, notices) = + walk_chain(&config, None, MODEL, NOW(), &HashSet::new()).unwrap(); + assert_matches!(landing, Landing::Acp); + assert_eq!(selected, Selected { + entry: AuthEntry::Subscription(None), + generation: None, + }); + assert_eq!(notices, Vec::::new()); +} + +/// A spent ACP subscription falls through to the entry behind it. +/// +/// Claude Code owns the login, so there is no stored profile to cool down; only +/// the request's record of what it tried moves the walk past it. +#[test(tokio::test)] +async fn a_spent_acp_subscription_falls_through_to_the_api_key() { + let mut config = anthropic_config(&["subscription", "api_key"], SET_ENV_VAR); + config.subscription_flow = SubscriptionFlow::Acp; + let spent = attempt_on(Selected { + entry: AuthEntry::Subscription(None), + generation: None, + }); + let error = StreamError::subscription_exhausted("spent", None, None); + + let next = advance(&config, None, &spent, &error, MODEL, NOW()) + .await + .expect("the api key behind the subscription can serve the request"); + + assert_eq!(entry_of(&next), Some(AuthEntry::ApiKey(None))); + assert_matches!(next.route, Route::Http(Credential::ApiKey(_))); +} + +#[test] +fn acp_rejects_named_subscription_without_paid_fallback() { + let mut config = anthropic_config(&["subscription:personal", "api_key"], SET_ENV_VAR); + config.subscription_flow = SubscriptionFlow::Acp; + let error = walk_chain(&config, None, MODEL, NOW(), &HashSet::new()).unwrap_err(); + assert_matches!(error, ResolveError::Acp(AcpError::NamedSubscription { name }) if name == "personal"); +} + +#[test] +fn api_entry_before_acp_keeps_its_route() { + let mut config = anthropic_config(&["api_key", "subscription"], SET_ENV_VAR); + config.subscription_flow = SubscriptionFlow::Acp; + let (landing, selected, _) = walk_chain(&config, None, MODEL, NOW(), &HashSet::new()).unwrap(); + assert_matches!(landing, Landing::Ready(Credential::ApiKey(_))); + assert_eq!(selected.entry, AuthEntry::ApiKey(None)); +} + #[test] fn test_named_profile_resolves_to_bearer() { let config = anthropic_config(&["subscription:personal"], UNSET_ENV_VAR); @@ -410,10 +465,7 @@ async fn test_a_refused_api_key_falls_through_to_a_profile() { .expect("the profile behind the key can serve the request"); assert_eq!(entry_of(&next), Some(profile("personal"))); - assert_eq!( - next.credential, - Credential::Bearer("sk-personal".to_owned()) - ); + assert_matches!(next.route, Route::Http(Credential::Bearer(token)) if token == "sk-personal"); assert_eq!( next.switch.as_deref().unwrap(), "api key rejected, continuing with subscription (personal)" @@ -533,7 +585,7 @@ async fn test_advance_records_scoped_cooldown_and_moves_to_next_profile() { .expect("the chain has a second profile to fall to"); assert_eq!(entry_of(&next), Some(profile("work"))); - assert_eq!(next.credential, Credential::Bearer("sk-work".to_owned())); + assert_matches!(next.route, Route::Http(Credential::Bearer(token)) if token == "sk-work"); assert_eq!( next.switch.as_deref().unwrap(), "subscription (personal) limit reached, continuing with subscription (work)" diff --git a/crates/jp_llm/src/provider/anthropic_tests.rs b/crates/jp_llm/src/provider/anthropic_tests.rs index cca2191cf..c9972c22a 100644 --- a/crates/jp_llm/src/provider/anthropic_tests.rs +++ b/crates/jp_llm/src/provider/anthropic_tests.rs @@ -1828,7 +1828,7 @@ fn test_adaptive_thinking_with_structured_output() { /// thinking disabled. #[test] fn test_forced_tool_with_reasoning_returns_fallback() { - use crate::tool::{ToolDefinition, ToolDocs}; + use jp_tool::{ToolDefinition, ToolDocs}; let model = ModelDetails { id: (PROVIDER, "claude-sonnet-4-5").try_into().unwrap(), @@ -1902,7 +1902,7 @@ fn test_forced_tool_with_reasoning_returns_fallback() { /// up an escalating-nudge fallback that keeps thinking on. #[test] fn test_forced_tool_thinking_always_on_uses_escalating_nudge() { - use crate::tool::{ToolDefinition, ToolDocs}; + use jp_tool::{ToolDefinition, ToolDocs}; let model = ModelDetails { id: (PROVIDER, "claude-fable-5").try_into().unwrap(), @@ -1983,7 +1983,7 @@ fn test_forced_tool_thinking_always_on_uses_escalating_nudge() { /// of the reasoning config. #[test] fn test_forced_tool_thinking_always_on_reasoning_off_still_soft_forces() { - use crate::tool::{ToolDefinition, ToolDocs}; + use jp_tool::{ToolDefinition, ToolDocs}; let model = ModelDetails { id: (PROVIDER, "claude-fable-5").try_into().unwrap(), @@ -2048,7 +2048,7 @@ fn test_forced_tool_thinking_always_on_reasoning_off_still_soft_forces() { /// specific tool. #[test] fn test_forced_tool_function_multi_tool_preserves_name() { - use crate::tool::{ToolDefinition, ToolDocs}; + use jp_tool::{ToolDefinition, ToolDocs}; let model = ModelDetails { id: (PROVIDER, "claude-sonnet-4-5").try_into().unwrap(), @@ -2124,7 +2124,7 @@ fn test_fallback_any_satisfied_by_any_tool() { /// Without reasoning, forced `tool_choice` should NOT produce a fallback. #[test] fn test_forced_tool_without_reasoning_no_fallback() { - use crate::tool::{ToolDefinition, ToolDocs}; + use jp_tool::{ToolDefinition, ToolDocs}; let model = ModelDetails { id: (PROVIDER, "claude-3-haiku-20240307").try_into().unwrap(), diff --git a/crates/jp_llm/src/provider/cerebras.rs b/crates/jp_llm/src/provider/cerebras.rs index f9452ff5f..df24fe3ce 100644 --- a/crates/jp_llm/src/provider/cerebras.rs +++ b/crates/jp_llm/src/provider/cerebras.rs @@ -15,6 +15,7 @@ use jp_conversation::{ event::{ChatResponse, EventKind, ToolCallResponse}, thread::text_attachments_to_xml, }; +use jp_tool::ToolDefinition; use reqwest::header::{self, HeaderMap, HeaderValue}; use reqwest_eventsource::{Event as SseEvent, EventSource, retry::Never}; use serde::Deserialize; @@ -33,7 +34,6 @@ use crate::{ provider::trace_to_tmpfile, query::ChatQuery, stream::with_tool_call_keepalive, - tool::ToolDefinition, }; static PROVIDER: ProviderId = ProviderId::Cerebras; diff --git a/crates/jp_llm/src/provider/google.rs b/crates/jp_llm/src/provider/google.rs index 05a04eadd..da0908a2e 100644 --- a/crates/jp_llm/src/provider/google.rs +++ b/crates/jp_llm/src/provider/google.rs @@ -21,6 +21,7 @@ use jp_conversation::{ event::{ChatResponse, ConversationEvent, EventKind}, thread::{ThreadParts, text_attachments_to_xml}, }; +use jp_tool::ToolDefinition; use serde_json::{Map, Value}; use tracing::{debug, trace, warn}; @@ -34,7 +35,6 @@ use crate::{ event::{Event, EventMatcher, EventPatch, FinishReason, PatchAction}, model::{ModelDeprecation, ModelDetails, ReasoningDetails, ReasoningMode}, query::ChatQuery, - tool::ToolDefinition, }; static PROVIDER: ProviderId = ProviderId::Google; diff --git a/crates/jp_llm/src/provider/ollama.rs b/crates/jp_llm/src/provider/ollama.rs index cc0e18d01..df767148b 100644 --- a/crates/jp_llm/src/provider/ollama.rs +++ b/crates/jp_llm/src/provider/ollama.rs @@ -14,6 +14,7 @@ use jp_conversation::{ event::{ChatResponse, EventKind}, thread::text_attachments_to_xml, }; +use jp_tool::ToolDefinition; use ollama_rs::{ Ollama as Client, error::OllamaError, @@ -35,7 +36,6 @@ use crate::{ event::{Event, FinishReason}, model::ReasoningDetails, query::ChatQuery, - tool::{ToolDefinition, json_schema}, }; static PROVIDER: ProviderId = ProviderId::Ollama; @@ -423,7 +423,7 @@ fn convert_tools(tools: Vec) -> Result> { tools .into_iter() .map(|tool| { - let parameters = json_schema::inline(&tool.parameters) + let parameters = jp_tool::schema::inline(&tool.parameters) .as_object() .cloned() .unwrap_or_default(); diff --git a/crates/jp_llm/src/provider/ollama_tests.rs b/crates/jp_llm/src/provider/ollama_tests.rs index 74154c59b..1c1b0a1f3 100644 --- a/crates/jp_llm/src/provider/ollama_tests.rs +++ b/crates/jp_llm/src/provider/ollama_tests.rs @@ -1,7 +1,8 @@ +use jp_tool::{ToolDefinition, ToolDocs}; use serde_json::json; use super::*; -use crate::{query::Truncation, tool::ToolDocs}; +use crate::query::Truncation; /// Ollama drops `$ref` while decoding a tool's parameters, so a referenced type /// has to arrive expanded or the model sees a property with no type. diff --git a/crates/jp_llm/src/provider/openai.rs b/crates/jp_llm/src/provider/openai.rs index a95c3b05d..c51b3de40 100644 --- a/crates/jp_llm/src/provider/openai.rs +++ b/crates/jp_llm/src/provider/openai.rs @@ -22,6 +22,7 @@ use jp_conversation::{ thread::text_attachments_to_xml, }; use jp_credentials::CredentialStore; +use jp_tool::ToolDefinition; use openai_responses::{ Client, CreateError, StreamError as OpenaiStreamError, types::{self, Include, Request, SummaryConfig}, @@ -29,6 +30,7 @@ use openai_responses::{ use reqwest::header::{self, HeaderMap, HeaderName, HeaderValue}; use serde::Deserialize; use serde_json::{Map, Value}; +use tokio::time::{Instant, sleep}; use tracing::{debug, trace, warn}; use super::{EventStream, ModelDetails, Provider}; @@ -43,7 +45,6 @@ use crate::{ provider::trace_to_tmpfile, query::{ChatQuery, Truncation}, stream::with_tool_call_keepalive, - tool::{ToolDefinition, json_schema}, }; pub mod auth; @@ -116,6 +117,25 @@ const PERSISTED_REASONING: &str = "persisted_reasoning"; /// when the flag is present. const EXPLICIT_PROMPT_CACHING: &str = "explicit_prompt_caching"; +/// How long to keep retrying after a redeemed usage reset before giving up on +/// it. +/// +/// A redemption the account confirmed does not reach the responses host +/// instantly, so the request that prompted it is refused again for a short +/// while afterwards. +/// The credit is already spent by then, and the turn is unfinished: waiting it +/// out is what the user paid for, where abandoning the turn wastes both. +/// +/// Bounded because the alternative explanation — a window the credit does not +/// cover — looks identical from here, and that one never resolves. +const RESET_PROPAGATION_BUDGET: Duration = Duration::from_mins(1); + +/// How long to wait between attempts while a redeemed reset propagates. +/// +/// Fixed rather than backed off: the wait is already short, and every attempt +/// is an admission-stage rejection that bills nothing. +const RESET_RETRY_INTERVAL: Duration = Duration::from_secs(5); + /// How often to inject a synthetic keep-alive while a tool call is streaming. /// /// OpenAI emits the `function_call_arguments` deltas for a large tool call as a @@ -148,6 +168,15 @@ pub struct Openai { /// Where subscription requests go, after the environment override. codex_base_url: String, + /// How long a redeemed usage reset is given to reach the responses host. + /// + /// A field rather than a constant so a test can shrink the wait it is + /// pinning; production always takes [`RESET_PROPAGATION_BUDGET`]. + reset_budget: Duration, + + /// How long to wait between attempts while that reset propagates. + reset_interval: Duration, + /// The clients built for the most recently resolved credential and session. /// /// A turn issues several requests around tool execution, and resolution @@ -193,6 +222,8 @@ impl Openai { fixed_credential: None, base_url: env_override(&config.base_url_env, &config.base_url), codex_base_url: env_override(&config.codex_base_url_env, &config.codex_base_url), + reset_budget: RESET_PROPAGATION_BUDGET, + reset_interval: RESET_RETRY_INTERVAL, client_cache: Arc::new(Mutex::new(None)), }; @@ -214,10 +245,21 @@ impl Openai { fixed_credential: Some((credential, resolve::Attribution::default())), base_url: config.base_url.clone(), codex_base_url: config.codex_base_url.clone(), + reset_budget: RESET_PROPAGATION_BUDGET, + reset_interval: RESET_RETRY_INTERVAL, client_cache: Arc::new(Mutex::new(None)), } } + /// Shorten the wait for a redeemed reset, so a test pinning it runs in + /// milliseconds rather than the minute production allows. + #[cfg(test)] + pub(crate) fn with_reset_timing(mut self, budget: Duration, interval: Duration) -> Self { + self.reset_budget = budget; + self.reset_interval = interval; + self + } + /// Build a provider around an explicit subscription credential. /// /// Test seam for recording and replaying the subscription endpoint without @@ -255,11 +297,16 @@ impl Openai { /// /// Returns `None` when there is no chain to advance or nothing further in /// it, which the caller surfaces as the original, now-terminal error. + /// + /// `after_redemption` reports whether this turn already spent a reset + /// credit on the attempt's profile, which is what the recorded cooldown + /// depends on. async fn advance( &self, attempt: &resolve::Attempt, error: &StreamError, model: &str, + after_redemption: bool, ) -> Option { resolve::advance( &self.config, @@ -268,6 +315,7 @@ impl Openai { error, model, Utc::now(), + after_redemption, ) .await } @@ -533,7 +581,10 @@ impl Provider for Openai { // One redemption per turn. A plan holds few credits, and a window // that closes again right after being reopened is not a window a // second credit would fix. - let mut redeemed = false; + // + // The deadline is how long the reopened window is given to reach + // the responses host before the turn stops waiting on it. + let mut reopened_until: Option = None; // A credential refused or spent at admission is not a failure of // the request: the next entry in the chain can serve it. Each pass @@ -599,13 +650,18 @@ impl Provider for Openai { // subscription the user already paid for, instead of // falling through to per-token billing with allowance // still on the account. + // One credit reopens one window, so a limit reporting + // more than one spent window stays closed after the + // redemption. Spending a credit there buys nothing and + // the account has few to spend. if subscription && error.kind == StreamErrorKind::SubscriptionExhausted - && !redeemed + && reopened_until.is_none() + && error.quota_spent_windows <= 1 && let Some(notice) = this.redeem_reset_credit(&attempt, &session).await { - redeemed = true; + reopened_until = Some(Instant::now() + this.reset_budget); for notice in notices { yield Ok(Event::Notice(notice)); } @@ -613,7 +669,26 @@ impl Provider for Openai { continue; } - if let Some(mut next) = this.advance(&attempt, &error, &name).await { + // The window this turn reopened has not reached the + // host yet. The turn is unfinished and the credit is + // already spent, so it waits rather than falling + // through to per-token billing or abandoning the turn + // outright. + if subscription + && error.kind == StreamErrorKind::SubscriptionExhausted + && reopened_until.is_some_and(|until| Instant::now() < until) + { + for notice in notices { + yield Ok(Event::Notice(notice)); + } + sleep(this.reset_interval).await; + continue; + } + + if let Some(mut next) = this + .advance(&attempt, &error, &name, reopened_until.is_some()) + .await + { next.notices.splice(..0, notices); attempt = next; } else { @@ -3060,7 +3135,7 @@ fn convert_tools(tools: Vec) -> Vec { // strict mode for that one tool costs its adherence guarantee; // sending it strict costs the whole request, and every other tool // in it. - let strict = !json_schema::has_unconstrained_node(&tool.parameters); + let strict = !jp_tool::schema::has_unconstrained_node(&tool.parameters); types::Tool::Function { name: tool.name, diff --git a/crates/jp_llm/src/provider/openai/rate_limits.rs b/crates/jp_llm/src/provider/openai/rate_limits.rs index f3de5b5a4..b8f844e8f 100644 --- a/crates/jp_llm/src/provider/openai/rate_limits.rs +++ b/crates/jp_llm/src/provider/openai/rate_limits.rs @@ -96,10 +96,15 @@ impl Snapshot { /// The spent window, if either is spent. #[must_use] pub fn spent(&self) -> Option<&Window> { + self.spent_windows().next() + } + + /// Every spent window of this family, in report order. + pub fn spent_windows(&self) -> impl Iterator { [self.primary.as_ref(), self.secondary.as_ref()] .into_iter() .flatten() - .find(|window| window.is_spent()) + .filter(|window| window.is_spent()) } /// The fullest window worth warning about, if any. @@ -146,6 +151,7 @@ pub fn apply(error: &mut StreamError, headers: &HeaderMap, model: &str) { error.kind = StreamErrorKind::SubscriptionExhausted; error.quota_scope = Some(snapshot.scope()); error.quota_reset = window.resets_at; + error.quota_spent_windows = snapshot.spent_windows().count(); } /// The spent limit a request for `model` ran into, preferring the model's own diff --git a/crates/jp_llm/src/provider/openai/rate_limits_tests.rs b/crates/jp_llm/src/provider/openai/rate_limits_tests.rs index f6c111bf7..6e5529c30 100644 --- a/crates/jp_llm/src/provider/openai/rate_limits_tests.rs +++ b/crates/jp_llm/src/provider/openai/rate_limits_tests.rs @@ -1,6 +1,7 @@ use reqwest::header::{HeaderName, HeaderValue}; use super::*; +use crate::StreamErrorKind; /// The header set a real `200` from the subscription endpoint carries. /// @@ -130,6 +131,61 @@ fn test_apply_leaves_a_non_limit_error_alone() { } } +/// A reset credit reopens one window, so the count is what tells the caller +/// whether spending one can unblock the request at all. +#[test] +fn test_apply_counts_every_spent_window_of_the_family_it_records() { + let mut error = StreamError::rate_limit(None); + + apply( + &mut error, + &headers(&[ + ("x-codex-primary-used-percent", "100"), + ("x-codex-primary-window-minutes", "300"), + ("x-codex-primary-reset-after-seconds", "1800"), + ("x-codex-secondary-used-percent", "100"), + ("x-codex-secondary-window-minutes", "10080"), + ("x-codex-secondary-reset-after-seconds", "604800"), + ]), + "gpt-5.6-luna", + ); + + assert_eq!(error.kind, StreamErrorKind::SubscriptionExhausted); + assert_eq!(error.quota_spent_windows, 2); +} + +#[test] +fn test_apply_counts_a_single_spent_window() { + let mut error = StreamError::rate_limit(None); + + apply( + &mut error, + &headers(&[ + ("x-codex-primary-used-percent", "100"), + ("x-codex-primary-window-minutes", "10080"), + ("x-codex-primary-reset-after-seconds", "604800"), + ("x-codex-secondary-used-percent", "0"), + ("x-codex-secondary-window-minutes", "0"), + ("x-codex-secondary-reset-after-seconds", "0"), + ]), + "gpt-5.6-luna", + ); + + assert_eq!(error.kind, StreamErrorKind::SubscriptionExhausted); + assert_eq!(error.quota_spent_windows, 1); +} + +/// Headers reporting nothing spent leave the error alone, count included. +#[test] +fn test_apply_counts_nothing_when_no_window_is_spent() { + let mut error = StreamError::rate_limit(None); + + apply(&mut error, &headers(LIVE_HEADERS), "gpt-5.6-luna"); + + assert_eq!(error.kind, StreamErrorKind::RateLimit); + assert_eq!(error.quota_spent_windows, 0); +} + #[test] fn test_parse_all_reads_both_families_from_a_live_response() { let snapshots = parse_all(&headers(LIVE_HEADERS)); diff --git a/crates/jp_llm/src/provider/openai/redeem_tests.rs b/crates/jp_llm/src/provider/openai/redeem_tests.rs index 3afdbe4af..049a602c5 100644 --- a/crates/jp_llm/src/provider/openai/redeem_tests.rs +++ b/crates/jp_llm/src/provider/openai/redeem_tests.rs @@ -1,16 +1,29 @@ -//! Redeeming reset credits against a local stand-in for the account endpoints. +//! Redeeming reset credits, against a local stand-in for the account endpoints +//! and the subscription host. +//! +//! The account confirms a redemption before the responses host honours it, so +//! the request that prompted it keeps being refused for a short while. +//! The tests at the end pin what the turn does in that gap. -use std::sync::{Arc, Mutex}; +use std::{ + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; +use futures::StreamExt as _; use jp_config::AppConfig; +use jp_conversation::{ConversationStream, thread::Thread}; use serde_json::Value; use tokio::{ io::{AsyncReadExt as _, AsyncWriteExt as _}, net::{TcpListener, TcpStream}, }; -use super::{Openai, resolve}; -use crate::credential::Credential; +use super::{Openai, Provider as _, resolve}; +use crate::{credential::Credential, model::ModelDetails, query::ChatQuery}; /// Serve the credit listing and the redemption endpoint until the test ends, /// recording each redemption body. @@ -159,3 +172,158 @@ async fn test_an_unanswered_redemption_retries_the_subscription() { ) ); } + +/// How many times each endpoint was called. +#[derive(Default)] +struct Calls { + responses: AtomicUsize, + consume: AtomicUsize, +} + +/// A `429` carrying the headers that mark one spent account window. +/// +/// One window, so the turn is willing to spend a credit on it; a reset an hour +/// out, so recording that timing would be visible as an hour-long cooldown. +const REFUSED: &str = concat!( + "HTTP/1.1 429 Too Many Requests\r\n", + "content-type: application/json\r\n", + "x-codex-primary-used-percent: 100\r\n", + "x-codex-primary-window-minutes: 300\r\n", + "x-codex-primary-reset-after-seconds: 3600\r\n", + "x-codex-secondary-used-percent: 0\r\n", + "x-codex-secondary-window-minutes: 0\r\n", + "x-codex-secondary-reset-after-seconds: 0\r\n", + "connection: close\r\n", + "content-length: 37\r\n\r\n", + r#"{"error":{"message":"limit reached"}}"#, +); + +fn json_response(body: &str) -> String { + format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\nconnection: \ + close\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ) +} + +/// Serve the subscription host until the test drops the listener. +/// +/// Every responses request is refused, so the turn keeps waiting for the reset +/// it redeemed for as long as it is willing to. +fn spawn_host(listener: TcpListener) -> Arc { + let calls = Arc::new(Calls::default()); + let served = calls.clone(); + + tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + + // Read to the end of the headers, then drain the declared body, so + // the client sees a complete exchange rather than a reset peer. + let mut request = Vec::new(); + let mut byte = [0u8; 1]; + while !request.ends_with(b"\r\n\r\n") { + match socket.read(&mut byte).await { + Ok(0) | Err(_) => break, + Ok(_) => request.push(byte[0]), + } + } + + let head = String::from_utf8_lossy(&request).to_ascii_lowercase(); + let length: usize = head + .split("content-length:") + .nth(1) + .and_then(|rest| rest.split("\r\n").next()) + .and_then(|value| value.trim().parse().ok()) + .unwrap_or(0); + let mut body = vec![0u8; length]; + drop(socket.read_exact(&mut body).await); + + let response = if head.contains("rate-limit-reset-credits/consume") { + served.consume.fetch_add(1, Ordering::SeqCst); + json_response(r#"{"code":"reset","windows_reset":1}"#) + } else if head.contains("rate-limit-reset-credits") { + json_response(r#"{"credits":[],"available_count":2}"#) + } else { + served.responses.fetch_add(1, Ordering::SeqCst); + REFUSED.to_owned() + }; + + drop(socket.write_all(response.as_bytes()).await); + drop(socket.shutdown().await); + } + }); + + calls +} + +fn query() -> ChatQuery { + ChatQuery::from(Thread { + system_prompt: None, + sections: vec![], + attachments: vec![], + events: ConversationStream::new_test().with_turn("hello"), + }) +} + +/// A turn that spends a credit and is refused again does not give the reset one +/// immediate retry and then abandon the turn: it keeps asking while the +/// redemption propagates. +/// +/// The bound matters as much as the retrying. +/// A window the credit does not cover is refused identically and never +/// resolves, so the wait has to end. +#[tokio::test] +async fn test_a_redeemed_reset_is_waited_out_and_then_given_up_on() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let calls = spawn_host(listener); + + let mut config = AppConfig::new_test().providers.llm.openai; + config.codex_base_url = format!("http://{address}/codex"); + // Production allows a minute at five-second intervals. The behaviour under + // test is the shape of the wait, not its length. + let provider = Openai::with_subscription_credential( + &config, + "bearer-token".to_owned(), + "acct-1".to_owned(), + ) + .with_reset_timing(Duration::from_millis(250), Duration::from_millis(25)); + + let stream = provider + .chat_completion_stream( + &ModelDetails::empty("openai/gpt-5.6-sol".parse().unwrap()), + query(), + ) + .await + .unwrap(); + + // Far longer than the shortened budget, so it fails a wait that never ends + // rather than hanging the suite. + let events = tokio::time::timeout(Duration::from_secs(30), stream.collect::>()) + .await + .unwrap_or_else(|_| { + panic!( + "the wait for a redeemed reset never ended after {} attempts", + calls.responses.load(Ordering::SeqCst) + ) + }); + + assert!( + events.last().is_some_and(Result::is_err), + "a host that never honours the reset has to end the turn: {events:?}" + ); + + // One credit, however many times the request is refused. + assert_eq!(calls.consume.load(Ordering::SeqCst), 1); + + // Two attempts is the old behaviour: the one that hit the limit, and the + // single immediate retry after redeeming. + let attempts = calls.responses.load(Ordering::SeqCst); + assert!( + attempts > 2, + "expected the turn to keep retrying while the reset propagated, got {attempts} attempts" + ); +} diff --git a/crates/jp_llm/src/provider/openai/resolve.rs b/crates/jp_llm/src/provider/openai/resolve.rs index e2b2d8984..2569753a6 100644 --- a/crates/jp_llm/src/provider/openai/resolve.rs +++ b/crates/jp_llm/src/provider/openai/resolve.rs @@ -338,10 +338,18 @@ pub(super) async fn advance( error: &StreamError, model: &str, now: DateTime, + after_redemption: bool, ) -> Option { let selected = spent.selected.as_ref()?; - record_outcome(store, selected, spent.generation, error, now); + record_outcome( + store, + selected, + spent.generation, + error, + now, + after_redemption, + ); // Whether or not the store took the record, this entry is out for the rest // of the request. Carrying that in memory is what lets an `api_key`, which @@ -527,12 +535,17 @@ fn retire(document: &mut StoreDocument, profile: &str) { /// A cooldown takes a profile out of use for up to seven days and a re-login /// marker until the user acts, and a failure that says nothing about the /// credential earns neither. +/// +/// `after_redemption` says whether a reset credit was spent on this profile +/// earlier in the same turn, which changes how far the reported reset timing +/// can be trusted. fn record_outcome( store: Option<&CredentialStore>, spent: &AuthEntry, generation: Option, error: &StreamError, now: DateTime, + after_redemption: bool, ) { // Only a stored profile has state to record against. let (AuthEntry::Subscription(Some(profile)), Some(generation)) = (spent, generation) else { @@ -549,7 +562,20 @@ fn record_outcome( } StreamErrorKind::SubscriptionExhausted | StreamErrorKind::InsufficientQuota => { let scope = error.quota_scope.as_deref().unwrap_or(SCOPE_ACCOUNT); - let until = cooldown_until(error.quota_reset, now); + + // A reset credit spent this turn reopened one of the limit's + // windows, so these headers describe a usage state JP itself just + // changed, and the window they report may not be the one the credit + // reopened. Taking their reset timing at face value can retire a + // profile for a week over a long window the user is not actually + // blocked on. The short default applies instead: it expires on its + // own, and the next request asks the provider rather than a guess. + let reported = if after_redemption { + None + } else { + error.quota_reset + }; + let until = cooldown_until(reported, now); debug!(profile, scope, %until, "Recording quota cooldown."); store.record_cooldown( CATEGORY_LLM, diff --git a/crates/jp_llm/src/provider/openai/resolve_tests.rs b/crates/jp_llm/src/provider/openai/resolve_tests.rs index 5795d7945..8a867a33c 100644 --- a/crates/jp_llm/src/provider/openai/resolve_tests.rs +++ b/crates/jp_llm/src/provider/openai/resolve_tests.rs @@ -8,7 +8,9 @@ use std::{ }; use chrono::TimeZone as _; -use jp_credentials::{CredentialBackend, InMemoryCredentialBackend, MAX_COOLDOWN}; +use jp_credentials::{ + CredentialBackend, DEFAULT_COOLDOWN, InMemoryCredentialBackend, MAX_COOLDOWN, +}; use jp_storage::resource_lock::InMemoryResourceLocker; use super::*; @@ -139,6 +141,18 @@ fn token_credential(token: &str) -> StoredCredential { } } +/// The profile's account-scoped cooldown, as stored. +fn cooldown(store: &CredentialStore, profile: &str) -> Option> { + store + .load() + .unwrap() + .profiles(CATEGORY_LLM, PROVIDER_OPENAI)? + .get(profile)? + .cooldowns + .get(SCOPE_ACCOUNT) + .copied() +} + fn insert(store: &CredentialStore, profile: &str, credential: &StoredCredential) { store .mutate(|document| { @@ -519,6 +533,7 @@ async fn test_advance_without_a_selected_entry_is_terminal() { &StreamError::auth_rejected("refused"), "gpt-5.6", now(), + false, ) .await; @@ -542,6 +557,7 @@ async fn test_advance_records_relogin_and_moves_to_the_next_entry() { &StreamError::auth_rejected("token revoked"), "gpt-5.6", now(), + false, ) .await .unwrap(); @@ -582,6 +598,7 @@ async fn test_advance_records_a_cooldown_for_an_exhausted_profile() { &StreamError::new(StreamErrorKind::SubscriptionExhausted, "limit reached"), "gpt-5.6", now(), + false, ) .await .unwrap(); @@ -602,6 +619,86 @@ async fn test_advance_records_a_cooldown_for_an_exhausted_profile() { assert!(!stored.needs_relogin); } +/// Without a redemption, the reported reset is the best evidence there is, so +/// the profile stays out until the window it names reopens. +#[tokio::test] +async fn test_advance_records_the_reported_reset_for_an_exhausted_window() { + let store = store(); + insert(&store, "only", &token_credential("bearer-1")); + + let reset = now() + chrono::TimeDelta::hours(5); + let mut error = StreamError::new(StreamErrorKind::SubscriptionExhausted, "limit reached"); + error.quota_reset = Some(reset); + + advance( + &config(vec![AuthEntry::Subscription(Some("only".to_owned()))]), + Some(&store), + &attempt_on(AuthEntry::Subscription(Some("only".to_owned())), Some(0)), + &error, + "gpt-5.6", + now(), + false, + ) + .await; + + assert_eq!(cooldown(&store, "only"), Some(reset)); +} + +/// The turn already spent a reset credit against this profile, so the usage +/// state these headers describe is one JP itself just changed. +/// Recording their reset timing would retire a profile the user can still reach +/// for the full length of the window they named — a week, at the cap. +#[tokio::test] +async fn test_advance_after_a_redemption_records_only_the_short_default() { + let store = store(); + insert(&store, "only", &token_credential("bearer-1")); + + let mut error = StreamError::new(StreamErrorKind::SubscriptionExhausted, "limit reached"); + error.quota_reset = Some(now() + chrono::TimeDelta::days(30)); + + advance( + &config(vec![AuthEntry::Subscription(Some("only".to_owned()))]), + Some(&store), + &attempt_on(AuthEntry::Subscription(Some("only".to_owned())), Some(0)), + &error, + "gpt-5.6", + now(), + true, + ) + .await; + + assert_eq!(cooldown(&store, "only"), Some(now() + DEFAULT_COOLDOWN)); +} + +/// A shortened cooldown must still take the spent profile out of the walk, or +/// the chain would hand the same refused credential back. +#[tokio::test] +async fn test_advance_after_a_redemption_still_reaches_the_next_entry() { + let store = store(); + insert(&store, "first", &token_credential("bearer-1")); + insert(&store, "second", &token_credential("bearer-2")); + + let mut error = StreamError::new(StreamErrorKind::SubscriptionExhausted, "limit reached"); + error.quota_reset = Some(now() + chrono::TimeDelta::days(30)); + + let next = advance( + &config(vec![ + AuthEntry::Subscription(Some("first".to_owned())), + AuthEntry::Subscription(Some("second".to_owned())), + ]), + Some(&store), + &attempt_on(AuthEntry::Subscription(Some("first".to_owned())), Some(0)), + &error, + "gpt-5.6", + now(), + true, + ) + .await + .unwrap(); + + assert_eq!(next.credential, Credential::Bearer("bearer-2".to_owned())); +} + #[tokio::test] async fn test_advance_records_nothing_for_a_malformed_request() { // A `400` is deterministic: every credential in the chain answers it the @@ -621,6 +718,7 @@ async fn test_advance_records_nothing_for_a_malformed_request() { &StreamError::other("System messages are not allowed (HTTP 400)"), "gpt-5.6", now(), + false, ) .await; @@ -651,6 +749,7 @@ async fn test_advance_records_nothing_for_a_context_window_overflow() { &StreamError::context_window_exceeded("prompt too long"), "gpt-5.6", now(), + false, ) .await; @@ -677,6 +776,7 @@ async fn test_advance_is_terminal_when_the_chain_has_nothing_left() { &StreamError::auth_rejected("token revoked"), "gpt-5.6", now(), + false, ) .await; @@ -839,6 +939,7 @@ async fn test_a_refused_api_key_falls_through_to_a_subscription() { &StreamError::new(StreamErrorKind::InsufficientQuota, "no credit"), "gpt-5.6", now(), + false, ) .await .unwrap(); @@ -877,7 +978,7 @@ async fn test_an_entry_is_tried_once_per_request() { Some(AuthEntry::ApiKey(Some("work".to_owned()))) ); - let second = advance(&chain, None, &first, &refused, "gpt-5.6", now()) + let second = advance(&chain, None, &first, &refused, "gpt-5.6", now(), false) .await .unwrap(); assert_eq!( @@ -885,7 +986,7 @@ async fn test_an_entry_is_tried_once_per_request() { Some(AuthEntry::ApiKey(Some("personal".to_owned()))) ); - let third = advance(&chain, None, &second, &refused, "gpt-5.6", now()).await; + let third = advance(&chain, None, &second, &refused, "gpt-5.6", now(), false).await; assert!(third.is_none(), "the chain offered a key it already tried"); } diff --git a/crates/jp_llm/src/provider/openai_compat.rs b/crates/jp_llm/src/provider/openai_compat.rs index 028142009..9fda49325 100644 --- a/crates/jp_llm/src/provider/openai_compat.rs +++ b/crates/jp_llm/src/provider/openai_compat.rs @@ -28,6 +28,7 @@ use jp_conversation::{ ConversationStream, event::{ChatResponse, EventKind, ToolCallResponse}, }; +use jp_tool::ToolDefinition; use reqwest_eventsource::Event as SseEvent; use serde::Deserialize; use serde_json::{Value, json}; @@ -38,7 +39,6 @@ use crate::{ error::StreamError, event::{Event, FinishReason}, stream::aggregator::reasoning::ReasoningExtractor, - tool::ToolDefinition, }; #[derive(Debug, Deserialize)] diff --git a/crates/jp_llm/src/provider/openai_tests.rs b/crates/jp_llm/src/provider/openai_tests.rs index 8d9ba9075..b6222b2c4 100644 --- a/crates/jp_llm/src/provider/openai_tests.rs +++ b/crates/jp_llm/src/provider/openai_tests.rs @@ -381,10 +381,10 @@ mod parameters_with_strict_mode { } mod convert_tools { + use jp_tool::{ToolDefinition, ToolDocs}; use serde_json::json; use super::super::convert_tools; - use crate::tool::{ToolDefinition, ToolDocs}; /// One converted tool, as it goes on the wire. fn converted(parameters: serde_json::Value) -> serde_json::Value { diff --git a/crates/jp_llm/src/provider/openrouter.rs b/crates/jp_llm/src/provider/openrouter.rs index 93f1e3efc..c922165b0 100644 --- a/crates/jp_llm/src/provider/openrouter.rs +++ b/crates/jp_llm/src/provider/openrouter.rs @@ -225,7 +225,7 @@ fn call( yield flush; } patch @ Event::Patch(_) => yield patch, - keep_alive @ Event::KeepAlive => yield keep_alive, + progress @ (Event::KeepAlive | Event::ToolCallPending { .. } | Event::ToolCallPendingEnd { .. }) => yield progress, notice @ Event::Notice(_) => yield notice, } } diff --git a/crates/jp_llm/src/provider/vllm_tests.rs b/crates/jp_llm/src/provider/vllm_tests.rs index dda98c3a5..58982554b 100644 --- a/crates/jp_llm/src/provider/vllm_tests.rs +++ b/crates/jp_llm/src/provider/vllm_tests.rs @@ -7,13 +7,11 @@ use jp_conversation::{ event::{ChatRequest, ChatResponse, ToolCallRequest, ToolCallResponse}, thread::Thread, }; +use jp_tool::{ToolDefinition, ToolDocs}; use serde_json::{Map, json}; use super::*; -use crate::{ - query::Truncation, - tool::{ToolDefinition, ToolDocs}, -}; +use crate::query::Truncation; fn qwen_model() -> VllmModel { serde_json::from_value(json!({ diff --git a/crates/jp_llm/src/query.rs b/crates/jp_llm/src/query.rs index 77957d589..84bcfa0e2 100644 --- a/crates/jp_llm/src/query.rs +++ b/crates/jp_llm/src/query.rs @@ -1,7 +1,46 @@ +use camino::Utf8PathBuf; use jp_config::assistant::tool_choice::ToolChoice; use jp_conversation::thread::Thread; +use jp_tool::{InvocationContext, ToolDefinition}; +use url::Url; -use crate::tool::ToolDefinition; +use crate::stream::EventStream; + +/// Host resources available to a provider-owned continuation loop. +#[derive(Debug, Clone)] +pub struct QueryContext { + /// Stable logical working directory, independent of temporary session + /// files. + pub root: Utf8PathBuf, + /// JP's in-process MCP endpoint, with policy controlled through Host + /// channels. + pub mcp_endpoint: Option, + /// Host identity for scoping derived agent files. + /// Auxiliary requests that have no conversation owner leave this unset. + pub invocation: Option, +} + +/// Who dispatches the tool calls reported in a provider stream. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ToolExecution { + /// JP submits each model-requested call through its MCP connection. + #[default] + Caller, + /// The external agent submits calls; JP controls their pending + /// interactions. + Agent { + /// MCP metadata field that carries the provider's tool-call identifier. + correlation_key: &'static str, + }, +} + +/// A provider stream and its tool-dispatch contract. +pub struct QueryStream { + /// Events for the response, retained across tool phases for an agent loop. + pub events: EventStream, + /// Whether tool calls are submitted by JP or by the external agent. + pub execution: ToolExecution, +} /// Whether the provider may drop input to make a request fit the model's /// context window. diff --git a/crates/jp_llm/src/stream/chain.rs b/crates/jp_llm/src/stream/chain.rs index b59d22d09..cf1f7a4d8 100644 --- a/crates/jp_llm/src/stream/chain.rs +++ b/crates/jp_llm/src/stream/chain.rs @@ -118,7 +118,11 @@ impl EventChain { } // Pass through immediately — not part of the content stream. - Event::Patch(_) | Event::KeepAlive | Event::Notice(_) => vec![event], + Event::Patch(_) + | Event::KeepAlive + | Event::ToolCallPending { .. } + | Event::ToolCallPendingEnd { .. } + | Event::Notice(_) => vec![event], } } @@ -165,7 +169,11 @@ impl EventChain { } // Pass through immediately — not part of the content stream. - Event::Patch(_) | Event::KeepAlive | Event::Notice(_) => vec![event], + Event::Patch(_) + | Event::KeepAlive + | Event::ToolCallPending { .. } + | Event::ToolCallPendingEnd { .. } + | Event::Notice(_) => vec![event], } } diff --git a/crates/jp_llm/src/test.rs b/crates/jp_llm/src/test.rs index 60ac22a00..b106311f3 100644 --- a/crates/jp_llm/src/test.rs +++ b/crates/jp_llm/src/test.rs @@ -20,6 +20,7 @@ use jp_conversation::{ thread::{Thread, ThreadBuilder}, }; use jp_test::mock::{Snap, Vcr}; +use jp_tool::{ToolDefinition, ToolDocs}; use crate::{ event::{Event, FinishReason}, @@ -27,7 +28,6 @@ use crate::{ model::ModelDetails, provider::{ProviderTestRoute, provider_test_support}, query::{ChatQuery, Truncation}, - tool::{ToolDefinition, ToolDocs}, }; /// Fail when a model calls a tool with arguments its schema does not declare. @@ -747,7 +747,11 @@ pub async fn run_chat_completion_mode( }); } } - Event::Patch(_) | Event::KeepAlive | Event::Notice(_) => {} + Event::Patch(_) + | Event::KeepAlive + | Event::ToolCallPending { .. } + | Event::ToolCallPendingEnd { .. } + | Event::Notice(_) => {} Event::Finished(reason) => { for mut event in builder.drain() { event.timestamp = diff --git a/crates/jp_llm/src/tool.rs b/crates/jp_llm/src/tool.rs deleted file mode 100644 index da9db388f..000000000 --- a/crates/jp_llm/src/tool.rs +++ /dev/null @@ -1,1431 +0,0 @@ -//! Tool call utilities. - -pub mod builtin; -pub mod executor; -pub mod json_schema; - -use std::{ffi::OsStr, fmt, process::Stdio, sync::Arc}; - -pub use builtin::BuiltinTool; -use camino::Utf8Path; -use indexmap::IndexMap; -use jp_config::{ - conversation::tool::{CommandConfig, ToolConfigWithDefaults, ToolSource}, - types::command::shell_command_line, -}; -use jp_conversation::event::ToolCallResponse; -use jp_mcp::{ - RawContent, ResourceContents, - id::{McpServerId, McpToolId}, -}; -use jp_tool::{Action, Outcome, Question}; -use json_schema::{Node, merge_description}; -use minijinja::{Environment, ErrorKind as MinijinjaErrorKind, value::ValueKind}; -use serde_json::{Map, Value, json}; -use tokio::{ - io::{AsyncBufReadExt, AsyncReadExt, BufReader}, - process::Command, -}; -use tokio_util::sync::CancellationToken; -use tracing::{error, info, trace, warn}; - -use crate::error::ToolError; - -/// Documentation for a single tool parameter. -#[derive(Debug, Clone)] -pub struct ParameterDocs { - pub summary: Option, - pub description: Option, - pub examples: Option, -} - -impl ParameterDocs { - #[must_use] - pub fn is_empty(&self) -> bool { - self.description.is_none() && self.examples.is_none() - } -} - -/// Documentation for a single tool. -#[derive(Debug, Clone, Default)] -pub struct ToolDocs { - pub summary: Option, - pub description: Option, - pub examples: Option, - pub parameters: IndexMap, -} - -impl ToolDocs { - #[must_use] - pub fn is_empty(&self) -> bool { - self.description.is_none() - && self.examples.is_none() - && self.parameters.values().all(ParameterDocs::is_empty) - } - - /// The short description used for the tool schema sent to the LLM. - /// - /// Returns `summary` if set, otherwise falls back to `description`. - #[must_use] - pub fn schema_description(&self) -> Option<&str> { - self.summary.as_deref().or(self.description.as_deref()) - } - - /// Build `ToolDocs` from a tool's configuration. - #[must_use] - pub fn from_config(config: &ToolConfigWithDefaults) -> Self { - let summary = config.summary().map(str::to_owned); - let description = config.description().map(str::to_owned); - let examples = config.examples().map(str::to_owned); - - let parameters = config - .parameters() - .iter() - .filter_map(|(param_name, param_cfg)| { - let summary = param_cfg - .summary - .as_deref() - .or(param_cfg.description.as_deref()) - .map(str::to_owned); - let desc = param_cfg.description.as_deref().map(str::to_owned); - let ex = param_cfg.examples.as_deref().map(str::to_owned); - - if summary.is_none() && desc.is_none() && ex.is_none() { - return None; - } - - Some((param_name.to_owned(), ParameterDocs { - summary, - description: desc, - examples: ex, - })) - }) - .collect(); - - Self { - summary, - description, - examples, - parameters, - } - } -} - -/// The outcome of a tool execution. -/// -/// This type represents the possible results of executing a tool's underlying -/// command or MCP call, without any interactive prompts. -/// The caller is responsible for: -/// -/// 1. Handling permission prompts **before** calling -/// [`ToolDefinition::execute()`]. -/// 2. Handling [`ExecutionOutcome::NeedsInput`] by prompting the user or -/// assistant. -/// 3. Handling result editing **after** receiving the outcome. -/// -/// # Example Flow -/// -/// ```text -/// ToolExecutor (jp_cli) ToolDefinition (jp_llm) -/// ───────────────────── ────────────────────── -/// │ -/// ├── [AwaitingPermission] -/// │ prompt_permission() -/// │ -/// ├── [Running] -/// │ ────────────────────────────► execute() -/// │ │ -/// │ ◄──────────────────────────── ExecutionOutcome -/// ├── [AwaitingInput] (if NeedsInput) -/// │ prompt_question() -/// │ ────────────────────────────► execute() (with answer) -/// │ │ -/// │ ◄──────────────────────────── ExecutionOutcome -/// ├── [AwaitingResultEdit] -/// │ prompt_result_edit() -/// │ -/// └── [Completed] -/// ``` -#[derive(Debug)] -pub enum ExecutionOutcome { - /// Tool executed and produced a result. - Completed { - /// The tool call ID (for correlation with the request). - id: String, - - /// The execution result. - /// - /// If an error occurred, it means the tool ran, but reported an error. - result: Result, - }, - - /// Tool needs additional input before it can complete. - /// - /// The caller should: - /// - /// 1. Present the question to the user (or delegate to the assistant) - /// 2. Collect the answer - /// 3. Call [`ToolDefinition::execute()`] again with the answer in `answers` - NeedsInput { - /// The tool call ID. - id: String, - - /// The question to ask. - question: Question, - }, - - /// Tool execution was cancelled via the cancellation token. - /// - /// This occurs when the user interrupts tool execution (e.g., Ctrl+C during - /// a long-running command). - Cancelled { - /// The tool call ID. - id: String, - }, -} - -impl ExecutionOutcome { - /// Convert the outcome to a [`ToolCallResponse`]. - /// - /// This is useful for building the final response to send to the LLM after - /// any post-processing (e.g., result editing) is complete. - /// - /// # Note - /// - /// For [`ExecutionOutcome::NeedsInput`], this returns a placeholder - /// response. - /// The caller should typically handle `NeedsInput` specially rather than - /// converting it directly to a response. - #[must_use] - pub fn into_response(self) -> ToolCallResponse { - match self { - Self::Completed { id, result } => ToolCallResponse { id, result }, - Self::NeedsInput { id, question } => ToolCallResponse { - id, - result: Ok(format!("Tool requires additional input: {}", question.text)), - }, - Self::Cancelled { id } => ToolCallResponse { - id, - result: Ok("Tool execution cancelled by user.".to_string()), - }, - } - } - - /// Returns the tool call ID. - #[must_use] - pub fn id(&self) -> &str { - match self { - Self::Completed { id, .. } | Self::NeedsInput { id, .. } | Self::Cancelled { id } => id, - } - } - - /// Returns `true` if this is a `NeedsInput` outcome. - #[must_use] - pub fn needs_input(&self) -> bool { - matches!(self, Self::NeedsInput { .. }) - } - - /// Returns `true` if this is a `Cancelled` outcome. - #[must_use] - pub fn is_cancelled(&self) -> bool { - matches!(self, Self::Cancelled { .. }) - } - - /// Returns `true` if this is a `Completed` outcome with a successful - /// result. - #[must_use] - pub fn is_success(&self) -> bool { - matches!(self, Self::Completed { result: Ok(_), .. }) - } -} - -/// Result of running a tool command. -/// -/// This is the single parsing point for all tool command output. -/// Both tool execution and argument formatting go through this type, ensuring -/// consistent handling of `Outcome` variants (including error traces). -#[derive(Debug)] -pub enum CommandResult { - /// Tool produced content. - Success(String), - - /// Tool reported a transient error (can be retried). - TransientError { - /// The error message. - message: String, - - /// The error trace (source chain from the tool process). - trace: Vec, - }, - - /// Tool reported a fatal error. - FatalError(String), - - /// Tool needs additional input before it can continue. - NeedsInput(Question), - - /// Tool was cancelled via the cancellation token. - Cancelled, - - /// stdout wasn't valid `Outcome` JSON. - /// - /// Falls back to treating stdout as plain text. - /// The `success` flag indicates the process exit status. - RawOutput { - /// Raw stdout content. - stdout: String, - - /// Raw stderr content. - stderr: String, - - /// Whether the process exited successfully. - success: bool, - }, - - /// Tool emitted a well-formed `needs_input` whose question id is invalid - /// (empty, or contains a `.`, which is reserved as the inquiry-id - /// separator). - /// - /// Surfaced as a tool-level error so the malformed inquiry is dropped - /// before any inquiry event is constructed. - InvalidInquiry { - /// The offending question id, for the diagnostic trace. - question_id: String, - }, - - /// Tool emitted a payload shaped like a `needs_input` outcome (top-level - /// `"type": "needs_input"`) that failed to deserialize for a reason other - /// than an invalid question id: a field with the wrong shape, a missing - /// field, or a local-tool binary emitting an older wire protocol than this - /// build parses. - /// - /// Surfaced as a tool-level error rather than [`Self::RawOutput`] so a - /// protocol mismatch is loud, instead of silently handing the raw JSON to - /// the model as tool output. - MalformedInquiry { - /// The deserialization error, for the diagnostic trace and the - /// model-facing message. - detail: String, - }, -} - -impl CommandResult { - /// Format a transient error message including trace details. - /// - /// If the trace is empty, returns just the message. - /// Otherwise appends the trace entries so the LLM (or user) can see the - /// root cause. - #[must_use] - pub fn format_error(message: &str, trace: &[String]) -> String { - if trace.is_empty() { - message.to_owned() - } else { - format!("{message}\n\nTrace:\n{}", trace.join("\n")) - } - } - - /// Convert to a `Result` suitable for tool call responses. - /// - /// - `Success` → `Ok(content)` - /// - `TransientError` → `Err(json with message + trace)` - /// - `FatalError` → `Err(raw json)` - /// - `NeedsInput` → handled separately by callers (this panics) - /// - `Cancelled` → `Ok(cancellation message)` - /// - `RawOutput` → `Ok(stdout)` if success, `Err(json)` if failure - pub fn into_tool_result(self, name: &str) -> Result { - match self { - Self::Success(content) => Ok(content), - Self::TransientError { message, trace } => Err(json!({ - "message": message, - "trace": trace, - }) - .to_string()), - Self::FatalError(raw) => Err(raw), - Self::Cancelled => Ok("Tool execution cancelled by user.".to_string()), - Self::RawOutput { - stdout, - stderr, - success, - } => { - if success { - Ok(stdout) - } else { - Err(json!({ - "message": format!("Tool '{name}' execution failed."), - "stderr": stderr, - "stdout": stdout, - }) - .to_string()) - } - } - Self::InvalidInquiry { question_id } => { - error!( - tool = name, - question_id = %question_id, - "tool produced an invalid inquiry: question id must be non-empty and must not \ - contain '.'" - ); - Err( - "tool produced an invalid inquiry: question id must be non-empty and must not \ - contain '.'" - .to_owned(), - ) - } - Self::MalformedInquiry { detail } => { - error!( - tool = name, - %detail, - "tool produced a malformed inquiry that could not be parsed" - ); - Err(format!( - "tool '{name}' produced a malformed inquiry that could not be parsed: {detail}" - )) - } - Self::NeedsInput(_) => { - unreachable!("NeedsInput should be handled by the caller") - } - } - } -} - -/// Receives a running tool's stderr lines as they arrive. -/// -/// Called from the forwarder's read loop, so it must not block: the loop has to -/// keep draining or the child fills its pipe and the tool call never completes. -/// A consumer that falls behind drops rather than stalls. -pub type StderrSink = Arc; - -/// Identity of a tool invocation, used to tag stderr lines forwarded to -/// tracing. -/// -/// Pass `None` to disable stderr forwarding (e.g. for argument-formatting -/// invocations where stderr is not meaningful to the user). -#[derive(Clone)] -pub struct ToolTrace<'a> { - pub id: &'a str, - pub name: &'a str, - - /// Where to send each line for display, in addition to tracing. - /// - /// `None` when nothing is watching, which is the common case: tracing and - /// the accumulated buffer are unaffected either way. - pub stderr: Option, -} - -impl fmt::Debug for ToolTrace<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("ToolTrace") - .field("id", &self.id) - .field("name", &self.name) - .field("stderr", &self.stderr.is_some()) - .finish() - } -} - -/// Custom minijinja formatter used by [`run_tool_command`]. -/// -/// Scalars (strings, numbers, booleans) render raw — a template like -/// `{{tool.arguments.title}}` produces the bare string, not a JSON-quoted one. -/// Composites (sequences, maps, other iterables) serialize as JSON, so -/// `{{tool}}` and `{{context}}` produce valid JSON blobs without needing an -/// explicit `| tojson` filter at every call site. -/// `null`/undefined render as the literal `null`, matching the JSON convention -/// used by tool authors. -/// -/// Safe strings (e.g. the output of the `tojson` filter) pass through unchanged -/// so explicit opt-in JSON rendering continues to work. -fn format_tool_template_value( - out: &mut minijinja::Output<'_>, - _state: &minijinja::State<'_, '_>, - value: &minijinja::value::Value, -) -> Result<(), minijinja::Error> { - if value.is_safe() { - return write!(out, "{value}").map_err(Into::into); - } - - match value.kind() { - ValueKind::None | ValueKind::Undefined => write!(out, "null").map_err(Into::into), - ValueKind::String | ValueKind::Bool | ValueKind::Number => { - write!(out, "{value}").map_err(Into::into) - } - // Composites serialize as JSON so tool authors don't have to remember - // `| tojson` for every `{{tool}}` / `{{context}}` interpolation. - _ => { - let json = serde_json::to_string(value).map_err(|error| { - minijinja::Error::new( - MinijinjaErrorKind::BadSerialization, - "failed to serialize value as JSON", - ) - .with_source(error) - })?; - out.write_str(&json).map_err(Into::into) - } - } -} - -/// Run a tool command asynchronously with cancellation support. -/// -/// This is the **single entry point** for running tool commands (both execution -/// and argument formatting). -/// It handles: -/// -/// 1. Template rendering via [`minijinja`] -/// 2. Process spawning via Tokio's [`Command`] -/// 3. Cancellation via [`CancellationToken`] -/// 4. Parsing stdout as [`jp_tool::Outcome`] -/// 5. Forwarding the child's stderr to tracing (when `trace_as` is `Some`) -/// -/// # Panics -/// -/// Panics if tokio fails to attach the piped stdout/stderr handles to the -/// spawned child. -/// Both are requested via `Stdio::piped()`, so this is not expected to happen -/// in practice. -pub async fn run_tool_command( - command: CommandConfig, - ctx: Value, - root: &Utf8Path, - cancellation_token: CancellationToken, - trace_as: Option>, -) -> Result { - let CommandConfig { - program, - args, - shell, - } = command; - - let mut env = Environment::new(); - env.set_formatter(format_tool_template_value); - let tmpl = Arc::new(env); - - let program = tmpl - .render_str(&program, &ctx) - .map_err(|error| ToolError::TemplateError { - data: program.clone(), - error, - })?; - - let args = args - .iter() - .map(|s| tmpl.render_str(s, &ctx)) - .collect::, _>>() - .map_err(|error| ToolError::TemplateError { - data: args.join(" "), - error, - })?; - - let mut cmd = if shell { - // `program` is shell syntax and used verbatim; `args` are shell-quoted - // so multi-word arguments keep their boundaries. - let shell_cmd = shell_command_line(&program, &args); - - let mut cmd = Command::new("sh"); - cmd.arg("-c").arg(&shell_cmd); - cmd - } else { - let mut cmd = Command::new(&program); - cmd.args(&args); - cmd - }; - - // Isolate the child from JP's process group so terminal signals - // (Ctrl+C / SIGINT) don't kill it. JP manages tool lifecycle via - // the cancellation token, not Unix signals. - #[cfg(unix)] - cmd.process_group(0); - - // Ensure the child is killed when the tokio task is aborted on - // cancellation. Without this the process would be orphaned. - cmd.kill_on_drop(true); - - let mut child = cmd - .current_dir(root.as_std_path()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|error| ToolError::SpawnError { - command: format!( - "{} {}", - cmd.as_std().get_program().to_string_lossy(), - cmd.as_std() - .get_args() - .filter_map(OsStr::to_str) - .collect::>() - .join(" ") - ), - error, - })?; - - let stdout = child.stdout.take().expect("stdout piped"); - let stderr = child.stderr.take().expect("stderr piped"); - - let run = async { - tokio::try_join!( - read_all(stdout), - forward_stderr(stderr, trace_as), - child.wait(), - ) - }; - - tokio::select! { - biased; - () = cancellation_token.cancelled() => Ok(CommandResult::Cancelled), - result = run => Ok(match result { - Ok((stdout, stderr, status)) => { - parse_command_output(&stdout, &stderr, status.success()) - } - Err(error) => CommandResult::RawOutput { - stdout: String::new(), - stderr: error.to_string(), - success: false, - }, - }), - } -} - -/// Drain a child pipe into a byte buffer. -async fn read_all(mut pipe: impl tokio::io::AsyncRead + Unpin) -> std::io::Result> { - let mut buf = Vec::new(); - pipe.read_to_end(&mut buf).await?; - Ok(buf) -} - -/// Drain a child's stderr into a byte buffer, optionally forwarding each line -/// to tracing as it arrives. -/// -/// Uses byte-level line reading so non-UTF-8 stderr doesn't terminate the -/// forwarder. -async fn forward_stderr( - pipe: impl tokio::io::AsyncRead + Unpin, - trace_as: Option>, -) -> std::io::Result> { - let mut reader = BufReader::new(pipe); - let mut all = Vec::new(); - let mut line = Vec::new(); - - loop { - line.clear(); - if reader.read_until(b'\n', &mut line).await? == 0 { - break; - } - - if let Some(ToolTrace { id, name, stderr }) = &trace_as { - let text = String::from_utf8_lossy(&line); - let trimmed = text.trim_end_matches(['\n', '\r']); - if !trimmed.is_empty() { - trace!(target: "tool::stderr", tool_id = id, tool_name = name, "{trimmed}"); - - if let Some(sink) = stderr { - sink(trimmed); - } - } - } - - all.extend_from_slice(&line); - } - - Ok(all) -} - -/// Parse raw command output into a [`CommandResult`]. -/// -/// Tries to deserialize stdout as [`jp_tool::Outcome`]. -/// If that fails, falls back to [`CommandResult::RawOutput`]. -fn parse_command_output(stdout: &[u8], stderr: &[u8], success: bool) -> CommandResult { - let stdout_str = String::from_utf8_lossy(stdout); - - match serde_json::from_str::(&stdout_str) { - Ok(Outcome::Success { content }) => CommandResult::Success(content), - Ok(Outcome::Error { - transient, - message, - trace, - }) => { - if transient { - CommandResult::TransientError { message, trace } - } else { - CommandResult::FatalError(stdout_str.into_owned()) - } - } - Ok(Outcome::NeedsInput { question }) => CommandResult::NeedsInput(question), - // A payload shaped like a `needs_input` outcome that fails to - // deserialize must become a tool-level error, not `RawOutput`: - // silently handing the raw JSON to the model hides the failure (a - // stale local-tool binary emitting an older wire shape than this build - // parses, an invalid question id, a missing field) and leaves the - // model to invent an explanation. Output that is not an `Outcome` at - // all stays `RawOutput`. - Err(error) => { - let value = serde_json::from_str::(&stdout_str).ok(); - let is_needs_input = value - .as_ref() - .and_then(|v| v.get("type")) - .and_then(Value::as_str) - == Some("needs_input"); - - if !is_needs_input { - return CommandResult::RawOutput { - stdout: stdout_str.into_owned(), - stderr: String::from_utf8_lossy(stderr).into_owned(), - success, - }; - } - - let question_id = value - .as_ref() - .and_then(|v| v.get("question")) - .and_then(|q| q.get("id")) - .and_then(Value::as_str); - - match question_id { - // The id itself is the problem: empty, or containing the `.` - // reserved as the inquiry-id separator (`QuestionId` rejects - // both). - Some(id) if id.is_empty() || id.contains('.') => CommandResult::InvalidInquiry { - question_id: id.to_owned(), - }, - // Some other field failed to parse (wrong shape, missing - // field, protocol skew). - _ => CommandResult::MalformedInquiry { - detail: error.to_string(), - }, - } - } - } -} - -/// Identity of the conversation an invocation belongs to. -/// -/// Surfaced to local tools through the rendered template `context` (as -/// `context.workspace_id` and `context.conversation_id`) so a tool can scope -/// any state it persists to the originating workspace and conversation. -#[derive(Debug, Clone, Default)] -pub struct InvocationContext { - pub workspace_id: String, - pub conversation_id: String, -} - -/// The definition of a tool. -/// -/// The definition source is either a [`ToolConfig`] for `local` tools, or a -/// combination of `ToolConfig` and MCP server information for `mcp` tools, or -/// hard-coded for definitions `builtin` tools. -/// -/// [`ToolConfig`]: jp_config::conversation::tool::ToolConfig -#[derive(Debug, Clone)] -pub struct ToolDefinition { - pub name: String, - pub docs: ToolDocs, - - /// JSON Schema for the tool's arguments, as its source declared it, with - /// configuration overrides applied. - /// - /// Adapting this to what a given API accepts belongs to that provider. - pub parameters: Value, -} - -impl ToolDefinition { - /// Coerce JSON-encoded argument strings to non-string schema types. - /// - /// Strings stay unchanged when the schema accepts strings or their contents - /// do not parse to a declared type. - pub fn coerce_arguments(&self, arguments: &mut Map) { - coerce_arguments_to_schema(arguments, &self.parameters); - } - - /// Execute the tool without any interactive prompts. - /// - /// This is a pure execution method that runs the tool's underlying command - /// or MCP call and returns an [`ExecutionOutcome`]. - /// All interactive decisions (permission prompts, result editing, question - /// handling) are the caller's responsibility. - /// - /// # Arguments - /// - /// - `id` - The tool call ID for correlation with the request - /// - `arguments` - The tool arguments (caller is responsible for any - /// pre-processing) - /// - `answers` - Pre-provided answers to tool questions (from previous - /// `NeedsInput`) - /// - `config` - Tool configuration - /// - `mcp_client` - MCP client for MCP tool execution - /// - `root` - Working directory for local tool execution - /// - `cancellation_token` - Token to cancel long-running execution - /// - `builtin_executors` - Registry of builtin tools - /// - /// # Returns - /// - /// - [`ExecutionOutcome::Completed`] - Tool finished (check inner `Result` - /// for success/error) - /// - [`ExecutionOutcome::NeedsInput`] - Tool needs user input to continue - /// - [`ExecutionOutcome::Cancelled`] - Execution was cancelled via the - /// token - /// - /// # Errors - /// - /// Returns [`ToolError`] for infrastructure errors (spawn failure, missing - /// command, etc.). - /// Tool-level errors (command returned non-zero) are returned as - /// `Ok(ExecutionOutcome::Completed { result: Err(...) })`. - /// - /// # Example - /// - /// ```ignore - /// loop { - /// match definition.execute(id, &args, &answers, ...).await? { - /// ExecutionOutcome::Completed { result, .. } => { - /// // Handle success or tool error - /// break result; - /// } - /// ExecutionOutcome::NeedsInput { question, .. } => { - /// // Prompt user for input - /// let answer = prompt_user(&question)?; - /// answers.insert(question.id, answer); - /// // Loop to retry with answer - /// } - /// ExecutionOutcome::Cancelled { .. } => { - /// break Ok("Cancelled".into()); - /// } - /// } - /// } - /// ``` - #[expect(clippy::too_many_arguments)] - pub async fn execute( - &self, - id: String, - arguments: Value, - answers: &IndexMap, - config: &ToolConfigWithDefaults, - mcp_client: &jp_mcp::Client, - root: &Utf8Path, - cancellation_token: CancellationToken, - builtin_executors: &builtin::BuiltinExecutors, - access: Option<&jp_tool::AccessPolicy>, - invocation: &InvocationContext, - stderr: Option, - ) -> Result { - let mut arguments = arguments; - if let Some(arguments) = arguments.as_object_mut() { - self.coerce_arguments(arguments); - } - info!(tool = %self.name, arguments = ?arguments, "Executing tool."); - - match config.source() { - ToolSource::Local { tool } => { - self.execute_local( - id, - arguments, - answers, - config, - tool.as_deref(), - root, - cancellation_token, - access, - invocation, - stderr, - ) - .await - } - ToolSource::Mcp { server, tool } => { - self.execute_mcp( - id, - arguments, - mcp_client, - server, - tool.as_deref(), - cancellation_token, - ) - .await - } - ToolSource::Builtin { tool } => { - self.execute_builtin(id, &arguments, answers, tool.as_deref(), builtin_executors) - .await - } - } - } - - /// Execute a local tool and return the outcome. - /// - /// This is the pure execution path for local tools. - /// It validates arguments, runs the command, and converts the result to an - /// `ExecutionOutcome`. - #[expect(clippy::too_many_arguments)] - async fn execute_local( - &self, - id: String, - mut arguments: Value, - answers: &IndexMap, - config: &ToolConfigWithDefaults, - tool: Option<&str>, - root: &Utf8Path, - cancellation_token: CancellationToken, - access: Option<&jp_tool::AccessPolicy>, - invocation: &InvocationContext, - stderr: Option, - ) -> Result { - let name = tool.unwrap_or(&self.name); - - // Apply configured defaults for missing parameters, then validate. - if let Some(args) = arguments.as_object_mut() { - apply_parameter_defaults(args, &self.parameters); - - if let Err(error) = validate_tool_arguments(args, &self.parameters) { - return Ok(ExecutionOutcome::Completed { - id, - result: Err(format!( - "Invalid arguments: {error}\n\nYou can call `describe_tools(tools: \ - [\"{name}\"])` to learn more about how to use the tool correctly." - )), - }); - } - } - - let ctx = json!({ - "tool": { - "name": name, - "arguments": &arguments, - "answers": answers, - "options": config.options(), - }, - "context": { - "action": Action::Run, - "root": root.as_str(), - "access": access, - "workspace_id": &invocation.workspace_id, - "conversation_id": &invocation.conversation_id, - }, - }); - - let Some(command) = config.command() else { - return Err(ToolError::MissingCommand); - }; - - let trace_as = ToolTrace { - id: &id, - name, - stderr, - }; - - match run_tool_command(command, ctx, root, cancellation_token, Some(trace_as)).await? { - CommandResult::Success(content) => Ok(ExecutionOutcome::Completed { - id, - result: Ok(content), - }), - CommandResult::NeedsInput(question) => { - Ok(ExecutionOutcome::NeedsInput { id, question }) - } - CommandResult::Cancelled => Ok(ExecutionOutcome::Cancelled { id }), - other => Ok(ExecutionOutcome::Completed { - id, - result: other.into_tool_result(name), - }), - } - } - - /// Execute an MCP tool and return the outcome. - /// - /// This is the pure execution path for MCP tools. - /// It calls the MCP server and converts the result to an - /// `ExecutionOutcome`. - async fn execute_mcp( - &self, - id: String, - arguments: Value, - mcp_client: &jp_mcp::Client, - server: &str, - tool: Option<&str>, - cancellation_token: CancellationToken, - ) -> Result { - let name = tool.unwrap_or(&self.name); - - let call_future = mcp_client.call_tool(name, server, &arguments); - - tokio::select! { - biased; - () = cancellation_token.cancelled() => { - info!(tool = %self.name, "MCP tool call cancelled"); - Ok(ExecutionOutcome::Cancelled { id }) - } - result = call_future => { - let result = result.map_err(ToolError::McpRunToolError)?; - - let content = result - .content - .into_iter() - .filter_map(|v| match v.raw { - RawContent::Text(v) => Some(v.text), - RawContent::Resource(v) => match v.resource { - ResourceContents::TextResourceContents { text, .. } => Some(text), - ResourceContents::BlobResourceContents { blob, .. } => Some(blob), - }, - RawContent::Image(_) | RawContent::Audio(_) | RawContent::ResourceLink(_) => None, - }) - .collect::>() - .join("\n\n"); - - let result = if result.is_error.unwrap_or_default() { - Err(content) - } else { - Ok(content) - }; - - Ok(ExecutionOutcome::Completed { id, result }) - } - } - } - - /// Execute a builtin tool and return the outcome. - /// - /// `source_name` is the implementation named by `source = - /// "builtin."`, which the registry is keyed on. - /// When absent, the implementation shares the tool's own name. - async fn execute_builtin( - &self, - id: String, - arguments: &Value, - answers: &IndexMap, - source_name: Option<&str>, - builtin_executors: &builtin::BuiltinExecutors, - ) -> Result { - let name = source_name.unwrap_or(&self.name); - let executor = builtin_executors - .get(name) - .ok_or_else(|| ToolError::NotFound { - name: name.to_owned(), - })?; - - let outcome = executor.execute(arguments, answers).await; - - Ok(match outcome { - jp_tool::Outcome::Success { content } => ExecutionOutcome::Completed { - id, - result: Ok(content), - }, - jp_tool::Outcome::Error { - message, - trace, - transient: _, - } => { - let error_msg = if trace.is_empty() { - message - } else { - format!("{message}\n\nTrace:\n{}", trace.join("\n")) - }; - ExecutionOutcome::Completed { - id, - result: Err(error_msg), - } - } - jp_tool::Outcome::NeedsInput { question } => { - ExecutionOutcome::NeedsInput { id, question } - } - }) - } - - /// Return the JSON Schema for the tool's parameters. - #[must_use] - pub fn to_parameters_schema(&self) -> Value { - self.parameters.clone() - } -} - -/// Split a description string into a short summary and remaining detail. -/// -/// If the text is short (single line, ≤120 chars), it is returned as the -/// summary with no remaining description. -/// -/// Otherwise, the first sentence is extracted as the summary. -/// A sentence ends at ` . ` or `.\n`. -/// The remainder becomes the description. -pub(crate) fn split_description(text: &str) -> (String, Option) { - let text = text.trim(); - - // Find the first sentence boundary. - // Look for ". " or ".\n" — a period followed by whitespace. - for (i, _) in text.match_indices('.') { - let after = i + 1; - if after >= text.len() { - // Period at end of string — the whole text is one sentence. - break; - } - - let next_byte = text.as_bytes()[after]; - if next_byte == b'\n' { - // Period followed by newline is always a sentence boundary. - } else if next_byte == b' ' { - // Period followed by space: only split if the next non-space - // character is uppercase (heuristic to skip abbreviations - // like "e.g. foo"). - let rest_after_space = text[after..].trim_start(); - if rest_after_space.is_empty() - || !rest_after_space - .chars() - .next() - .is_some_and(char::is_uppercase) - { - continue; - } - } else { - continue; - } - - { - let summary = text[..=i].trim().to_owned(); - let rest = text[after..].trim(); - - if rest.is_empty() { - return (summary, None); - } - - return (summary, Some(rest.to_owned())); - } - } - - // No sentence boundary found — take the first line. - if let Some(nl) = text.find('\n') { - let summary = text[..nl].trim().to_owned(); - let rest = text[nl..].trim(); - - if rest.is_empty() { - return (summary, None); - } - - return (summary, Some(rest.to_owned())); - } - - // Single long line, no period — return as-is. - (text.to_owned(), None) -} - -/// Coerce JSON-encoded argument strings to the types the schema declares. -fn coerce_arguments_to_schema(arguments: &mut Map, schema: &Value) { - coerce_object(arguments, &Node::root(schema)); -} - -fn coerce_object(arguments: &mut Map, node: &Node<'_>) { - for (name, property) in node.properties() { - if let Some(value) = arguments.get_mut(&name) { - coerce_value(value, &property); - } - } -} - -fn coerce_value(value: &mut Value, node: &Node<'_>) { - // Coercion repairs an argument the schema cannot take as written. A - // parameter that permits the string has nothing to repair, so parsing it - // would hand the tool a number or an object where the model sent text. - if let Value::String(raw) = &*value - && !node.permits(value) - && let Ok(parsed) = serde_json::from_str::(raw) - && node.permits(&parsed) - { - *value = parsed; - } - - match value { - Value::Object(arguments) => coerce_object(arguments, node), - Value::Array(values) => { - let Some(items) = node.items() else { - return; - }; - for value in values { - coerce_value(value, &items); - } - } - _ => {} - } -} - -/// Fill in configured default values for missing parameters. -/// -/// LLMs commonly omit parameters that have a `default` in the JSON schema, even -/// when those parameters are marked `required`. -/// This function patches the arguments map before validation so that such -/// omissions don't cause spurious "missing argument" errors and unnecessary LLM -/// retries. -fn apply_parameter_defaults(arguments: &mut Map, schema: &Value) { - apply_defaults_to(arguments, &Node::root(schema)); -} - -fn apply_defaults_to(arguments: &mut Map, node: &Node<'_>) { - for (name, property) in node.properties() { - if !arguments.contains_key(&name) { - if let Some(default) = property.default() { - let default = default.clone(); - arguments.insert(name, default); - } - continue; - } - - // Recurse into object fields. - if property.has_properties() - && let Some(object) = arguments.get_mut(&name).and_then(Value::as_object_mut) - { - apply_defaults_to(object, &property); - } - - // Recurse into array elements. - if let Some(items) = property.items() - && items.has_properties() - && let Some(values) = arguments.get_mut(&name).and_then(Value::as_array_mut) - { - for value in values.iter_mut() { - if let Some(object) = value.as_object_mut() { - apply_defaults_to(object, &items); - } - } - } - } -} - -fn validate_tool_arguments( - arguments: &Map, - schema: &Value, -) -> Result<(), ToolError> { - validate_arguments_against(arguments, &Node::root(schema)) -} - -fn validate_arguments_against( - arguments: &Map, - node: &Node<'_>, -) -> Result<(), ToolError> { - let properties = node.properties(); - - let unknown = arguments - .keys() - .filter(|name| !properties.iter().any(|(known, _)| known == *name)) - .cloned() - .collect::>(); - - let missing = properties - .iter() - .filter(|(name, _)| node.is_required(name) && !arguments.contains_key(name)) - .map(|(name, _)| name.clone()) - .collect::>(); - - if !missing.is_empty() || !unknown.is_empty() { - return Err(ToolError::Arguments { missing, unknown }); - } - - // Recurse into nested structures. - for (name, property) in properties { - let Some(value) = arguments.get(&name) else { - continue; - }; - - if let Some(object) = value.as_object() - && property.has_properties() - { - validate_arguments_against(object, &property)?; - } - - if let Some(items) = property.items() - && items.has_properties() - && let Some(values) = value.as_array() - { - for value in values { - if let Some(object) = value.as_object() { - validate_arguments_against(object, &items)?; - } - } - } - } - - Ok(()) -} - -/// Resolve all enabled tool definitions from config. -/// -/// If `forced_tool` is provided (e.g. from `ToolChoice::Function`), that tool -/// is included even when it is disabled, preventing a mismatch between -/// `tool_choice` and the declared tools list that some providers (notably -/// Google/Gemini) reject outright. -/// -/// A locked-off tool (`state = false`, `allow_toggle = never`) is the -/// exception: it is always dropped, even when named by `forced_tool`. -pub async fn tool_definitions( - configs: impl Iterator, - mcp_client: &jp_mcp::Client, - forced_tool: Option<&str>, -) -> Result, ToolError> { - let mut definitions = Vec::new(); - - for (name, config) in configs { - let enable = config.effective_enable(); - let forced = forced_tool.is_some_and(|f| f == name); - // Drop disabled tools, but keep a forced tool unless it is locked-off. - if !enable.is_enabled() && (!forced || enable.is_locked()) { - continue; - } - - // Drop MCP-backed tools whose server failed to start while marked - // optional. The server is absent from the running services map, and - // we don't want to hand the LLM a tool it cannot invoke. - if let ToolSource::Mcp { server, .. } = config.source() { - let server_id = McpServerId::new(server); - if !mcp_client.is_running(&server_id).await { - warn!( - tool = name, - server = %server, - "Skipping MCP tool: backing server is not running." - ); - continue; - } - } - - // A tool JP cannot describe to the provider is dropped rather than - // failing the query, matching the unavailable-server case above. A tool - // the caller named explicitly is the exception: silently omitting it - // would leave `tool_choice` pointing at a tool the provider never saw. - let definition = match resolve_tool(name, &config, mcp_client).await { - Ok(definition) => definition, - Err(error) if !forced => { - warn!( - tool = name, - %error, - "Skipping tool: its parameter schema could not be resolved." - ); - continue; - } - Err(error) => return Err(error), - }; - definitions.push(definition); - } - - Ok(definitions) -} - -/// Resolve a single tool definition and its documentation. -async fn resolve_tool( - name: &str, - config: &ToolConfigWithDefaults, - mcp_client: &jp_mcp::Client, -) -> Result { - let path = format!("conversation.tools.{name}.parameters"); - let definition = match config.source() { - ToolSource::Local { .. } | ToolSource::Builtin { .. } => ToolDefinition { - name: name.to_owned(), - docs: ToolDocs::from_config(config), - parameters: json_schema::from_config(&path, config.parameters())?, - }, - ToolSource::Mcp { server, tool } => { - resolve_mcp_tool(server, name, tool.as_deref(), config, mcp_client).await? - } - }; - - json_schema::validate(&path, &definition.parameters)?; - - Ok(definition) -} - -/// Resolve an MCP tool: fetch from server, merge config overrides, auto-split -/// descriptions into summary + detail. -async fn resolve_mcp_tool( - server: &str, - name: &str, - source_name: Option<&str>, - config: &ToolConfigWithDefaults, - mcp_client: &jp_mcp::Client, -) -> Result { - let mcp_tool = { - trace!(server = %server, tool = %name, "Fetching tool from MCP server"); - - let server_id = McpServerId::new(server); - mcp_client - .get_tool(&McpToolId::new(source_name.unwrap_or(name)), &server_id) - .await - .map_err(ToolError::McpGetToolError) - }?; - - let user_overrides = config.parameters(); - - // Merge tool-level description. - let merged_description = merge_description( - config.description().map(str::to_owned), - mcp_tool.description.as_deref(), - ); - - // The server's document is the source of truth; configuration may narrow - // it, and nothing else touches it. - let source = Value::Object(mcp_tool.input_schema.as_ref().clone()); - let parameters = json_schema::with_overrides( - &format!("conversation.tools.{name}.parameters"), - &source, - user_overrides, - )?; - - // Build docs with auto-split heuristic. - let has_user_summary = config.summary().is_some(); - - let (summary, description) = if has_user_summary { - // User provided explicit summary -- use config fields as-is. - ( - config.summary().map(str::to_owned), - config.description().map(str::to_owned), - ) - } else if let Some(ref desc) = merged_description { - let (s, d) = split_description(desc); - (Some(s), d) - } else { - (None, None) - }; - - let examples = config.examples().map(str::to_owned); - - // Per-parameter docs: auto-split MCP descriptions when user didn't override. - let param_docs = Node::root(¶meters) - .properties() - .into_iter() - .filter_map(|(pname, pnode)| { - let user_override = user_overrides.get(&pname); - let has_user_param_summary = user_override.and_then(|o| o.summary.as_ref()).is_some(); - - let (summary, desc) = if has_user_param_summary { - let summary = user_override - .and_then(|o| o.summary.as_deref()) - .or(user_override.and_then(|o| o.description.as_deref())) - .map(str::to_owned); - let desc = user_override - .and_then(|o| o.description.as_deref()) - .map(str::to_owned); - (summary, desc) - } else if let Some(resolved) = pnode.description() { - let (s, d) = split_description(resolved); - (Some(s), d) - } else { - (None, None) - }; - - let ex = user_override - .and_then(|o| o.examples.as_deref()) - .map(str::to_owned); - - if summary.is_none() && desc.is_none() && ex.is_none() { - return None; - } - - Some((pname, ParameterDocs { - summary, - description: desc, - examples: ex, - })) - }) - .collect(); - - let docs = ToolDocs { - summary, - description, - examples, - parameters: param_docs, - }; - - Ok(ToolDefinition { - name: name.to_owned(), - docs, - parameters, - }) -} - -#[cfg(test)] -#[path = "tool_tests.rs"] -mod tests; diff --git a/crates/jp_llm/src/tool/executor.rs b/crates/jp_llm/src/tool/executor.rs deleted file mode 100644 index adcf78946..000000000 --- a/crates/jp_llm/src/tool/executor.rs +++ /dev/null @@ -1,366 +0,0 @@ -use std::sync::Mutex; - -use async_trait::async_trait; -use camino::Utf8Path; -use indexmap::IndexMap; -use jp_config::conversation::tool::{RunMode, ToolConfigWithDefaults, ToolSource}; -use jp_conversation::event::{InquirySource, ToolCallRequest, ToolCallResponse}; -use jp_mcp::Client; -use jp_tool::Question; -use serde_json::{Map, Value}; -use tokio_util::sync::CancellationToken; - -use super::{StderrSink, ToolDefinition}; - -/// Trait for tool execution, enabling mock implementations for testing. -/// -/// This trait abstracts the execution of a single tool call, allowing the -/// `ToolCoordinator` to work with both real and mock executors. -/// -/// # Design -/// -/// The executor is intentionally simple - it just executes tools with given -/// answers. -/// All decision-making about question targets, static answers, and how to -/// handle `NeedsInput` is done by the coordinator, which has access to the tool -/// configuration. -#[async_trait] -pub trait Executor: Send + Sync { - /// Returns the tool call ID. - fn tool_id(&self) -> &str; - - /// Returns the tool name. - fn tool_name(&self) -> &str; - - /// Returns the tool call arguments. - /// - /// This is separate from [`permission_info()`] because arguments are always - /// available, while permission info is only present for tools that require - /// a permission prompt. - /// - /// [`permission_info()`]: Self::permission_info - fn arguments(&self) -> &Map; - - /// Returns information needed for permission prompting. - /// - /// Returns `None` if the tool doesn't need a permission prompt (e.g., - /// `RunMode::Unattended` or `RunMode::Skip`). - fn permission_info(&self) -> Option; - - /// Updates the arguments to use for execution. - /// - /// This is called after permission prompting if the user edited the - /// arguments (via `RunMode::Edit`). - /// The new arguments replace the original arguments from the tool call - /// request. - fn set_arguments(&mut self, args: Value); - - /// Executes the tool once with the given answers. - /// - /// This method performs a single execution pass. - /// If the tool needs additional input, it returns - /// `ExecutorResult::NeedsInput` and the coordinator handles prompting and - /// retrying. - /// - /// The executor doesn't know how questions should be answered - it just - /// reports that input is needed. - /// The coordinator looks up the tool configuration to determine whether to - /// prompt the user or ask the LLM. - /// - /// # Arguments - /// - /// - `answers` - Accumulated answers from previous `NeedsInput` responses - /// - `mcp_client` - MCP client for remote tool execution - /// - `root` - Project root directory - /// - `cancellation_token` - Token to cancel execution - /// - `stderr` - Receives the tool's stderr lines as they arrive, for a - /// caller showing progress while it runs. - /// `None` when nothing is watching; the lines still reach tracing and the - /// accumulated buffer either way. - async fn execute( - &self, - answers: &IndexMap, - mcp_client: &Client, - root: &Utf8Path, - cancellation_token: CancellationToken, - stderr: Option, - ) -> ExecutorResult; -} - -/// Abstraction over how executors are created for tool calls. -/// -/// This trait enables dependency injection of executor creation, allowing tests -/// to use mock executors without executing real shell commands. -pub trait ExecutorSource: Send + Sync { - /// Creates an executor for the given tool call request. - /// - /// Returns `None` if the tool cannot be resolved (e.g. missing from the - /// definitions). - fn create( - &self, - request: ToolCallRequest, - config: ToolConfigWithDefaults, - ) -> Option>; -} - -/// Result of a tool execution attempt. -/// -/// Tools may need multiple rounds of execution if they require additional -/// input. -/// This enum allows the executor to return control to the coordinator, which -/// decides how to handle the `NeedsInput` case by looking up the question -/// configuration. -#[derive(Debug)] -#[allow(clippy::large_enum_variant)] // NeedsInput variant is larger but rarely used -pub enum ExecutorResult { - /// Tool completed (success or error). - Completed(ToolCallResponse), - - /// Tool needs additional input before it can continue. - /// - /// The executor doesn't know who should answer - it just reports that input - /// is needed. - /// The coordinator looks up the question configuration to determine the - /// target: - /// - /// - `User`: Prompt the user interactively, then restart the tool - /// - `Assistant`: Format a response asking the LLM to re-run with answers - NeedsInput { - /// Tool call ID. - tool_id: String, - - /// Tool name (for persisting answers). - tool_name: String, - - /// The question that needs to be answered. - question: Question, - - /// Resolved provenance for the persisted `InquiryRequest`. - /// - /// Built-in tools may override this via `BuiltinTool::inquiry_source`; - /// local and MCP tools attribute the question to the tool by name. - source: InquirySource, - - /// Accumulated answers so far (for retry). - accumulated_answers: IndexMap, - }, -} - -/// A mock executor for testing that returns pre-configured results. -/// -/// This executor doesn't execute any real commands - it simply returns whatever -/// result is configured, making it ideal for testing tool coordination flows -/// without side effects. -/// -/// # Example -/// -/// ```ignore -/// let executor = MockExecutor::completed("call_1", "my_tool", "success output"); -/// let result = executor.execute(&answers, &client, &root, token).await; -/// assert!(result.is_completed()); -/// ``` -pub struct MockExecutor { - tool_id: String, - tool_name: String, - arguments: Map, - permission_info: Option, - result: Mutex>, -} - -impl MockExecutor { - /// Creates a mock executor that returns a successful completion. - #[must_use] - pub fn completed(tool_id: &str, tool_name: &str, output: &str) -> Self { - Self { - tool_id: tool_id.to_string(), - tool_name: tool_name.to_string(), - arguments: Map::new(), - permission_info: None, - result: Mutex::new(Some(ExecutorResult::Completed(ToolCallResponse { - id: tool_id.to_string(), - result: Ok(output.to_string()), - }))), - } - } - - /// Creates a mock executor that returns an error. - #[must_use] - pub fn error(tool_id: &str, tool_name: &str, error: &str) -> Self { - Self { - tool_id: tool_id.to_string(), - tool_name: tool_name.to_string(), - arguments: Map::new(), - permission_info: None, - result: Mutex::new(Some(ExecutorResult::Completed(ToolCallResponse { - id: tool_id.to_string(), - result: Err(error.to_string()), - }))), - } - } - - /// Sets the arguments for this executor. - #[must_use] - pub fn with_arguments(mut self, args: Map) -> Self { - self.arguments = args; - self - } - - /// Sets the permission info for this executor. - /// - /// If set, the executor will require permission prompting based on the - /// configured `RunMode`. - #[must_use] - pub fn with_permission_info(mut self, info: PermissionInfo) -> Self { - self.permission_info = Some(info); - self - } - - /// Sets a custom result for this executor. - #[must_use] - pub fn with_result(mut self, result: ExecutorResult) -> Self { - self.result = Mutex::new(Some(result)); - self - } -} - -#[async_trait] -impl Executor for MockExecutor { - fn tool_id(&self) -> &str { - &self.tool_id - } - - fn tool_name(&self) -> &str { - &self.tool_name - } - - fn arguments(&self) -> &Map { - &self.arguments - } - - fn permission_info(&self) -> Option { - self.permission_info.clone() - } - - fn set_arguments(&mut self, _args: Value) { - // No-op for mock executor - arguments don't affect the pre-configured - // result - } - - async fn execute( - &self, - _answers: &IndexMap, - _mcp_client: &Client, - _root: &Utf8Path, - _cancellation_token: CancellationToken, - _stderr: Option, - ) -> ExecutorResult { - self.result.lock().unwrap().take().unwrap_or_else(|| { - ExecutorResult::Completed(ToolCallResponse { - id: self.tool_id.clone(), - result: Err("MockExecutor: result already consumed".to_string()), - }) - }) - } -} - -/// An executor source for testing that returns pre-registered mock executors. -/// -/// This allows tests to inject mock executors for specific tool names without -/// executing any real shell commands. -/// -/// # Example -/// -/// ```ignore -/// let source = TestExecutorSource::new() -/// .with_executor("my_tool", |req| { -/// Box::new(MockExecutor::completed(&req.id, &req.name, "mock output")) -/// }); -/// -/// let coordinator = ToolCoordinator::new(tools_config, Arc::new(source)); -/// ``` -pub struct TestExecutorSource { - #[allow(clippy::type_complexity)] - factories: std::collections::HashMap< - String, - Box Box + Send + Sync>, - >, -} - -impl TestExecutorSource { - /// Creates a new empty test executor source. - #[must_use] - pub fn new() -> Self { - Self { - factories: std::collections::HashMap::new(), - } - } - - /// Registers a factory function for a tool name. - /// - /// When `create()` is called for this tool name, the factory will be - /// invoked to create the executor. - #[must_use] - pub fn with_executor(mut self, tool_name: &str, factory: F) -> Self - where - F: Fn(ToolCallRequest) -> Box + Send + Sync + 'static, - { - self.factories - .insert(tool_name.to_string(), Box::new(factory)); - self - } - - /// Returns stub [`ToolDefinition`]s for all registered tool names. - /// - /// Useful for passing to `run_turn_loop` so the availability check accepts - /// the tools this source can handle. - #[must_use] - pub fn tool_definitions(&self) -> Vec { - self.factories - .keys() - .map(|name| ToolDefinition { - name: name.clone(), - docs: super::ToolDocs::default(), - parameters: serde_json::json!({ "type": "object", "properties": {} }), - }) - .collect() - } -} - -impl Default for TestExecutorSource { - fn default() -> Self { - Self::new() - } -} - -impl ExecutorSource for TestExecutorSource { - fn create( - &self, - request: ToolCallRequest, - _config: ToolConfigWithDefaults, - ) -> Option> { - let factory = self.factories.get(&request.name)?; - Some(factory(request)) - } -} - -/// Information needed to prompt for tool execution permission. -/// -/// This struct contains all the data the `ToolPrompter` needs to show a -/// permission prompt to the user. -#[derive(Debug, Clone)] -pub struct PermissionInfo { - /// The tool call ID. - pub tool_id: String, - - /// The tool name. - pub tool_name: String, - - /// The tool source (builtin, local, MCP). - pub tool_source: ToolSource, - - /// The configured run mode. - pub run_mode: RunMode, - - /// The arguments to pass to the tool. - pub arguments: Value, -} diff --git a/crates/jp_llm/src/tool_tests.rs b/crates/jp_llm/src/tool_tests.rs deleted file mode 100644 index 5173063a8..000000000 --- a/crates/jp_llm/src/tool_tests.rs +++ /dev/null @@ -1,1293 +0,0 @@ -use async_trait::async_trait; -use jp_config::{ - AppConfig, Config as _, - conversation::tool::{PartialToolConfig, ToolConfig}, -}; -use jp_mcp::Client; -use jp_tool::Outcome; - -use super::*; - -struct EchoArguments; - -#[async_trait] -impl BuiltinTool for EchoArguments { - async fn execute(&self, arguments: &Value, _answers: &IndexMap) -> Outcome { - Outcome::Success { - content: arguments.to_string(), - } - } -} - -#[test] -fn test_execution_outcome_completed_success_into_response() { - let outcome = ExecutionOutcome::Completed { - id: "call_123".to_string(), - result: Ok("Tool output".to_string()), - }; - - let response = outcome.into_response(); - assert_eq!(response.id, "call_123"); - assert_eq!(response.result, Ok("Tool output".to_string())); -} - -#[test] -fn test_execution_outcome_completed_error_into_response() { - let outcome = ExecutionOutcome::Completed { - id: "call_456".to_string(), - result: Err("Tool failed".to_string()), - }; - - let response = outcome.into_response(); - assert_eq!(response.id, "call_456"); - assert_eq!(response.result, Err("Tool failed".to_string())); -} - -#[test] -fn test_execution_outcome_needs_input_into_response() { - let question = Question::text("q1", "What is your name?").unwrap(); - - let outcome = ExecutionOutcome::NeedsInput { - id: "call_789".to_string(), - question, - }; - - let response = outcome.into_response(); - assert_eq!(response.id, "call_789"); - assert!(response.result.is_ok()); - assert!( - response - .result - .unwrap() - .contains("requires additional input") - ); -} - -#[test] -fn test_execution_outcome_cancelled_into_response() { - let outcome = ExecutionOutcome::Cancelled { - id: "call_abc".to_string(), - }; - - let response = outcome.into_response(); - assert_eq!(response.id, "call_abc"); - assert!(response.result.is_ok()); - assert!(response.result.unwrap().contains("cancelled")); -} - -#[test] -fn test_execution_outcome_id() { - let completed = ExecutionOutcome::Completed { - id: "id1".to_string(), - result: Ok(String::new()), - }; - assert_eq!(completed.id(), "id1"); - - let needs_input = ExecutionOutcome::NeedsInput { - id: "id2".to_string(), - question: Question::text("q", "?").unwrap(), - }; - assert_eq!(needs_input.id(), "id2"); - - let cancelled = ExecutionOutcome::Cancelled { - id: "id3".to_string(), - }; - assert_eq!(cancelled.id(), "id3"); -} - -#[test] -fn test_execution_outcome_helper_methods() { - let success = ExecutionOutcome::Completed { - id: "1".to_string(), - result: Ok("output".to_string()), - }; - assert!(success.is_success()); - assert!(!success.needs_input()); - assert!(!success.is_cancelled()); - - let failure = ExecutionOutcome::Completed { - id: "2".to_string(), - result: Err("error".to_string()), - }; - assert!(!failure.is_success()); - assert!(!failure.needs_input()); - assert!(!failure.is_cancelled()); - - let needs_input = ExecutionOutcome::NeedsInput { - id: "3".to_string(), - question: Question::boolean("q", "?").unwrap(), - }; - assert!(!needs_input.is_success()); - assert!(needs_input.needs_input()); - assert!(!needs_input.is_cancelled()); - - let cancelled = ExecutionOutcome::Cancelled { - id: "4".to_string(), - }; - assert!(!cancelled.is_success()); - assert!(!cancelled.needs_input()); - assert!(cancelled.is_cancelled()); -} - -#[test] -fn parse_command_output_valid_needs_input() { - let stdout = br#"{"type":"needs_input","question":{"id":"confirm","text":"?","pre_amble":null,"answer_type":{"type":"boolean"},"default":null}}"#; - assert!(matches!( - parse_command_output(stdout, b"", true), - CommandResult::NeedsInput(_) - )); -} - -#[test] -fn parse_command_output_dotted_question_id_is_invalid_inquiry() { - let stdout = br#"{"type":"needs_input","question":{"id":"a.b","text":"?","pre_amble":null,"answer_type":{"type":"boolean"},"default":null}}"#; - let result = parse_command_output(stdout, b"", true); - assert!(matches!( - result, - CommandResult::InvalidInquiry { ref question_id } if question_id == "a.b" - )); - // Renders as a tool-level error, not raw text. - assert!(result.into_tool_result("t").is_err()); -} - -#[test] -fn parse_command_output_empty_question_id_is_invalid_inquiry() { - let stdout = br#"{"type":"needs_input","question":{"id":"","text":"?","pre_amble":null,"answer_type":{"type":"boolean"},"default":null}}"#; - let result = parse_command_output(stdout, b"", true); - assert!(matches!( - result, - CommandResult::InvalidInquiry { ref question_id } if question_id.is_empty() - )); - assert!(result.into_tool_result("t").is_err()); -} - -#[test] -fn parse_command_output_legacy_answer_type_shape_is_malformed_inquiry() { - // A stale local-tool binary emits the pre-082 externally-tagged answer - // type (`"answer_type":"Boolean"`) instead of the internally-tagged - // `{"type":"boolean"}` this build parses. The question id is valid, so - // the payload must surface as a tool-level error rather than being handed - // to the model as raw JSON. - let stdout = br#"{"type":"needs_input","question":{"id":"apply_changes","text":"Apply?","answer_type":"Boolean","default":true}}"#; - let result = parse_command_output(stdout, b"", true); - assert!( - matches!(result, CommandResult::MalformedInquiry { .. }), - "expected MalformedInquiry, got {result:?}" - ); - // Renders as a tool-level error, not raw text. - assert!(result.into_tool_result("fs_modify_file").is_err()); -} - -#[test] -fn parse_command_output_needs_input_missing_field_is_malformed_inquiry() { - // A `needs_input` missing a required question field fails to deserialize; - // with a valid id it is a malformed inquiry, not raw output. - let stdout = br#"{"type":"needs_input","question":{"id":"confirm"}}"#; - let result = parse_command_output(stdout, b"", true); - assert!( - matches!(result, CommandResult::MalformedInquiry { .. }), - "expected MalformedInquiry, got {result:?}" - ); - assert!(result.into_tool_result("t").is_err()); -} - -#[test] -fn parse_command_output_non_outcome_is_raw() { - assert!(matches!( - parse_command_output(b"plain text", b"", true), - CommandResult::RawOutput { .. } - )); -} - -#[test] -fn parse_command_output_non_needs_input_json_is_raw() { - // Valid JSON that is not an `Outcome` and not a `needs_input` payload - // stays raw output — the malformed-inquiry path must not swallow it. - let stdout = br#"{"some":"object","the_tool":"did not use the protocol"}"#; - assert!(matches!( - parse_command_output(stdout, b"", true), - CommandResult::RawOutput { .. } - )); -} - -/// Build a parameters schema from `(name, node, required)` triples. -fn schema(properties: [(&str, Value, bool); N]) -> Value { - let required = properties - .iter() - .filter(|(_, _, required)| *required) - .map(|(name, _, _)| Value::String((*name).to_owned())) - .collect::>(); - let properties = properties - .into_iter() - .map(|(name, node, _)| (name.to_owned(), node)) - .collect::>(); - - json!({ "type": "object", "properties": properties, "required": required }) -} - -/// A schema node of the given type. -fn param(kind: &str) -> Value { - json!({ "type": kind }) -} - -#[tokio::test] -async fn local_tool_rejects_scalar_enum_on_array_parameter() { - let partial: PartialToolConfig = serde_json::from_value(json!({ - "source": "local", - "parameters": { - "tags": { - "type": "array", - "enum": ["projects/jp", "task", "idea"], - "items": { "type": "string" } - } - } - })) - .unwrap(); - let tool = ToolConfig::from_partial(partial, vec![]).unwrap(); - let mut app = AppConfig::new_test(); - app.conversation - .tools - .insert("bear_note_create".to_owned(), tool); - let config = app.conversation.tools.get("bear_note_create").unwrap(); - - let error = resolve_tool("bear_note_create", &config, &Client::new(IndexMap::new())) - .await - .unwrap_err(); - - assert_eq!( - error.to_string(), - "Invalid schema at `conversation.tools.bear_note_create.parameters.tags.enum`: enum value \ - \"projects/jp\" has type string, but the schema requires array; use \ - `conversation.tools.bear_note_create.parameters.tags.items.enum` to constrain array \ - elements" - ); -} - -#[test] -fn coerces_json_strings_to_declared_parameter_types() { - let parameters = schema([ - ("path", param("string"), true), - ("start_line", param("integer"), false), - ("enabled", param("boolean"), false), - ( - "string_or_integer", - json!({ "type": ["string", "integer"] }), - false, - ), - ( - "patterns", - json!({ - "type": "array", - "items": { - "type": "object", - "properties": { "count": { "type": "integer" } }, - "required": ["count"] - } - }), - false, - ), - ]); - let mut arguments = json!({ - "path": "README.md", - "start_line": "1", - "enabled": "true", - "string_or_integer": "3", - "patterns": "[{\"count\":\"2\"}]" - }) - .as_object() - .cloned() - .unwrap(); - - ToolDefinition { - name: "test".to_owned(), - docs: ToolDocs::default(), - parameters, - } - .coerce_arguments(&mut arguments); - - assert_eq!( - Value::Object(arguments), - json!({ - "path": "README.md", - "start_line": 1, - "enabled": true, - "string_or_integer": "3", - "patterns": [{"count": 2}] - }) - ); -} - -/// Coercion repairs a string the schema cannot accept. -/// A parameter that declares no type accepts the string as written, so a -/// JSON-looking string reaches the tool as the text the model sent. -#[test] -fn leaves_strings_alone_for_a_parameter_with_no_declared_type() { - let parameters = schema([("value", json!({ "description": "Any JSON value." }), false)]); - let mut arguments = json!({ "value": "3" }).as_object().cloned().unwrap(); - - ToolDefinition { - name: "test".to_owned(), - docs: ToolDocs::default(), - parameters, - } - .coerce_arguments(&mut arguments); - - assert_eq!(Value::Object(arguments), json!({ "value": "3" })); -} - -/// A property with an `enum` and no `type` still says what it takes: the string -/// the model sent is not a member, and the number it parses to is. -#[test] -fn coerces_a_string_the_enum_excludes_into_the_member_it_parses_to() { - let parameters = schema([("value", json!({ "enum": [3] }), false)]); - let mut arguments = json!({ "value": "3" }).as_object().cloned().unwrap(); - - ToolDefinition { - name: "test".to_owned(), - docs: ToolDocs::default(), - parameters, - } - .coerce_arguments(&mut arguments); - - assert_eq!(Value::Object(arguments), json!({ "value": 3 })); -} - -/// The mirror case: the enum lists the string itself, so parsing it would -/// produce the one value the schema forbids. -#[test] -fn leaves_a_string_alone_when_the_enum_lists_it() { - let parameters = schema([("value", json!({ "enum": ["3"] }), false)]); - let mut arguments = json!({ "value": "3" }).as_object().cloned().unwrap(); - - ToolDefinition { - name: "test".to_owned(), - docs: ToolDocs::default(), - parameters, - } - .coerce_arguments(&mut arguments); - - assert_eq!(Value::Object(arguments), json!({ "value": "3" })); -} - -#[tokio::test] -async fn execute_coerces_json_strings_before_calling_tool() { - let partial: PartialToolConfig = serde_json::from_value(json!({ - "source": "builtin", - })) - .unwrap(); - let tool = ToolConfig::from_partial(partial, vec![]).unwrap(); - let mut app = AppConfig::new_test(); - app.conversation - .tools - .insert("echo_arguments".to_owned(), tool); - let config = app.conversation.tools.get("echo_arguments").unwrap(); - let definition = ToolDefinition { - name: "echo_arguments".to_owned(), - docs: ToolDocs::default(), - parameters: schema([("start_line", param("integer"), false)]), - }; - let builtins = builtin::BuiltinExecutors::new().register("echo_arguments", EchoArguments); - - let outcome = definition - .execute( - "call_1".to_owned(), - json!({"start_line": "1"}), - &IndexMap::new(), - &config, - &Client::new(IndexMap::new()), - Utf8Path::new("/tmp"), - CancellationToken::new(), - &builtins, - None, - &InvocationContext::default(), - None, - ) - .await - .unwrap(); - - let ExecutionOutcome::Completed { id, result } = outcome else { - panic!("expected completed tool call"); - }; - assert_eq!(id, "call_1"); - assert_eq!(result, Ok(r#"{"start_line":1}"#.to_owned())); -} - -#[test] -fn test_validate_tool_arguments() { - struct TestCase { - arguments: Map, - parameters: Value, - want: Result<(), ToolError>, - } - - let cases = vec![ - ("empty", TestCase { - arguments: Map::new(), - parameters: schema([]), - want: Ok(()), - }), - ("correct", TestCase { - arguments: Map::from_iter([("foo".to_owned(), json!("bar"))]), - parameters: schema([ - ("foo", param("string"), true), - ("bar", param("string"), false), - ]), - want: Ok(()), - }), - ("missing", TestCase { - arguments: Map::new(), - parameters: schema([("foo", param("string"), true)]), - want: Err(ToolError::Arguments { - missing: vec!["foo".to_owned()], - unknown: vec![], - }), - }), - ("unknown", TestCase { - arguments: Map::from_iter([("foo".to_owned(), json!("bar"))]), - parameters: schema([("bar", param("string"), false)]), - want: Err(ToolError::Arguments { - missing: vec![], - unknown: vec!["foo".to_owned()], - }), - }), - ("both", TestCase { - arguments: Map::from_iter([("foo".to_owned(), json!("bar"))]), - parameters: schema([("bar", param("string"), true)]), - want: Err(ToolError::Arguments { - missing: vec!["bar".to_owned()], - unknown: vec!["foo".to_owned()], - }), - }), - ]; - - for (name, test_case) in cases { - let result = validate_tool_arguments(&test_case.arguments, &test_case.parameters); - assert_eq!(result, test_case.want, "failed case: {name}"); - } -} - -#[test] -fn test_validate_nested_array_item_properties() { - // Mirrors the fs_modify_file schema: - // patterns: array of { old: string (required), new: string (required) } - let parameters = schema([ - ("path", param("string"), true), - ( - "patterns", - json!({ - "type": "array", - "items": { - "type": "object", - "properties": { - "old": { "type": "string" }, - "new": { "type": "string" } - }, - "required": ["old", "new"] - } - }), - true, - ), - ]); - - // Valid: correct inner fields. - let args = json!({ - "path": "src/lib.rs", - "patterns": [{"old": "foo", "new": "bar"}] - }); - assert_eq!( - validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Ok(()) - ); - - // Valid: multiple items. - let args = json!({ - "path": "src/lib.rs", - "patterns": [ - {"old": "a", "new": "b"}, - {"old": "c", "new": "d"} - ] - }); - assert_eq!( - validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Ok(()) - ); - - // Invalid: unknown inner field. - let args = json!({ - "path": "src/lib.rs", - "patterns": [{"old": "foo", "new": "bar", "extra": true}] - }); - assert_eq!( - validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Err(ToolError::Arguments { - missing: vec![], - unknown: vec!["extra".to_owned()], - }) - ); - - // Invalid: missing required inner field. - let args = json!({ - "path": "src/lib.rs", - "patterns": [{"old": "foo"}] - }); - assert_eq!( - validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Err(ToolError::Arguments { - missing: vec!["new".to_owned()], - unknown: vec![], - }) - ); - - // Invalid: wrong inner field names (the LLM hallucinated names). - let args = json!({ - "path": "src/lib.rs", - "patterns": [{"string_to_replace": "foo", "new_string": "bar"}] - }); - let err = validate_tool_arguments(args.as_object().unwrap(), ¶meters); - assert!(err.is_err()); - let ToolError::Arguments { missing, unknown } = err.unwrap_err() else { - panic!("expected Arguments error"); - }; - assert_eq!(missing, vec!["old".to_owned(), "new".to_owned()]); - // preserve_order: keys iterate in insertion order from json! macro - assert_eq!(unknown, vec![ - "string_to_replace".to_owned(), - "new_string".to_owned() - ]); - - // Valid: non-object array items are skipped (no crash). - let args = json!({ - "path": "src/lib.rs", - "patterns": ["not an object"] - }); - assert_eq!( - validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Ok(()) - ); - - // Valid: parameter is not an array (type mismatch, but not our job to check types). - let args = json!({ - "path": "src/lib.rs", - "patterns": "not an array" - }); - assert_eq!( - validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Ok(()) - ); -} - -#[test] -fn test_validate_nested_object_properties() { - let parameters = schema([ - ("name", param("string"), true), - ( - "config", - json!({ - "type": "object", - "properties": { - "verbose": { "type": "boolean" }, - "output": { "type": "string" } - }, - "required": ["output"] - }), - false, - ), - ]); - - // Valid. - let args = json!({ "name": "test", "config": { "verbose": true, "output": "out.txt" } }); - assert_eq!( - validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Ok(()) - ); - - // Valid: optional object param omitted entirely. - let args = json!({ "name": "test" }); - assert_eq!( - validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Ok(()) - ); - - // Invalid: unknown field inside the object. - let args = json!({ "name": "test", "config": { "output": "o", "bogus": 1 } }); - assert_eq!( - validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Err(ToolError::Arguments { - missing: vec![], - unknown: vec!["bogus".to_owned()], - }) - ); - - // Invalid: missing required field inside the object. - let args = json!({ "name": "test", "config": { "verbose": true } }); - assert_eq!( - validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Err(ToolError::Arguments { - missing: vec!["output".to_owned()], - unknown: vec![], - }) - ); -} - -/// A schema node of the given type, carrying a default value. -fn param_with_default(kind: &str, default: &Value) -> Value { - json!({ "type": kind, "default": default }) -} - -#[test] -fn test_apply_defaults_fills_missing_required_with_default() { - let parameters = schema([ - ("path", param("string"), true), - ( - "use_regex", - param_with_default("boolean", &json!(false)), - true, - ), - ]); - - let mut args: Map = Map::from_iter([("path".to_owned(), json!("src/lib.rs"))]); - - apply_parameter_defaults(&mut args, ¶meters); - - assert_eq!(args.get("path"), Some(&json!("src/lib.rs"))); - assert_eq!(args.get("use_regex"), Some(&json!(false))); -} - -#[test] -fn test_apply_defaults_does_not_overwrite_provided_values() { - let parameters = schema([( - "use_regex", - param_with_default("boolean", &json!(false)), - true, - )]); - - let mut args: Map = Map::from_iter([("use_regex".to_owned(), json!(true))]); - - apply_parameter_defaults(&mut args, ¶meters); - - assert_eq!(args.get("use_regex"), Some(&json!(true))); -} - -#[test] -fn test_apply_defaults_fills_optional_param_with_default() { - let parameters = schema([( - "verbose", - param_with_default("boolean", &json!(false)), - false, - )]); - - let mut args: Map = Map::new(); - apply_parameter_defaults(&mut args, ¶meters); - - assert_eq!(args.get("verbose"), Some(&json!(false))); -} - -#[test] -fn test_apply_defaults_skips_params_without_default() { - let parameters = schema([("path", param("string"), true)]); - - let mut args: Map = Map::new(); - apply_parameter_defaults(&mut args, ¶meters); - - assert!(!args.contains_key("path")); -} - -#[test] -fn test_apply_defaults_recurses_into_objects() { - let parameters = schema([( - "config", - json!({ - "type": "object", - "properties": { "verbose": { "type": "boolean", "default": true } } - }), - false, - )]); - - let mut args: Map = Map::from_iter([("config".to_owned(), json!({}))]); - - apply_parameter_defaults(&mut args, ¶meters); - - assert_eq!(args["config"]["verbose"], json!(true)); -} - -#[test] -fn test_apply_defaults_recurses_into_array_items() { - let parameters = schema([( - "items", - json!({ - "type": "array", - "items": { - "type": "object", - "properties": { "enabled": { "type": "boolean", "default": true } } - } - }), - true, - )]); - - let mut args: Map = Map::from_iter([( - "items".to_owned(), - json!([{"name": "a"}, {"name": "b", "enabled": false}]), - )]); - - apply_parameter_defaults(&mut args, ¶meters); - - let items = args["items"].as_array().unwrap(); - assert_eq!(items[0]["enabled"], json!(true)); - // Explicitly provided false is preserved. - assert_eq!(items[1]["enabled"], json!(false)); -} - -#[test] -fn test_apply_defaults_then_validate_passes() { - // Mirrors the fs_modify_file scenario: replace_using_regex is required - // with a default, and the LLM omits it. - let parameters = schema([ - ("path", param("string"), true), - ( - "replace_using_regex", - param_with_default("boolean", &json!(false)), - true, - ), - ]); - - let mut args: Map = Map::from_iter([("path".to_owned(), json!("README.md"))]); - - // Without defaults, validation would fail. - assert!(validate_tool_arguments(&args, ¶meters).is_err()); - - // After applying defaults, validation passes. - apply_parameter_defaults(&mut args, ¶meters); - assert!(validate_tool_arguments(&args, ¶meters).is_ok()); - assert_eq!(args["replace_using_regex"], json!(false)); -} - -#[test] -fn test_split_short_single_line() { - let (s, d) = split_description("Run cargo check."); - assert_eq!(s, "Run cargo check."); - assert_eq!(d, None); -} - -#[test] -fn test_split_short_no_period() { - let (s, d) = split_description("Run cargo check"); - assert_eq!(s, "Run cargo check"); - assert_eq!(d, None); -} - -#[test] -fn test_split_two_sentences() { - let (s, d) = split_description( - "Run cargo check on a package. Supports workspace packages and feature flags.", - ); - assert_eq!(s, "Run cargo check on a package."); - assert_eq!( - d, - Some("Supports workspace packages and feature flags.".to_owned()) - ); -} - -#[test] -fn test_split_multiline() { - let input = "Search for code in a repository.\n\nSupports regex and qualifiers."; - let (s, d) = split_description(input); - assert_eq!(s, "Search for code in a repository."); - assert_eq!(d, Some("Supports regex and qualifiers.".to_owned())); -} - -#[test] -fn test_split_multiline_no_period() { - let input = "First line without period\nSecond line here."; - let (s, d) = split_description(input); - assert_eq!(s, "First line without period"); - assert_eq!(d, Some("Second line here.".to_owned())); -} - -#[test] -fn test_split_preserves_abbreviations() { - // "e.g." should not be treated as a sentence boundary. - let (s, d) = split_description("Use e.g. foo or bar."); - assert_eq!(s, "Use e.g. foo or bar."); - assert_eq!(d, None); -} - -#[test] -fn test_split_long_single_line_with_period() { - let input = "This is a very long description that exceeds the threshold. It contains \ - additional details about the tool's behavior."; - let (s, d) = split_description(input); - assert_eq!( - s, - "This is a very long description that exceeds the threshold." - ); - assert!(d.is_some()); -} - -#[test] -fn test_split_empty() { - let (s, d) = split_description(""); - assert_eq!(s, ""); - assert_eq!(d, None); -} - -#[test] -fn test_split_trims_whitespace() { - let (s, d) = split_description(" hello "); - assert_eq!(s, "hello"); - assert_eq!(d, None); -} - -/// Regression: `{{tool}}` must render as valid JSON, including `null` for null -/// fields (not Jinja2's `none`). -/// Originally fixed with `AutoEscape::Json`, now handled by the custom -/// formatter which JSON-serializes composite values while leaving scalars -/// alone. -#[tokio::test] -#[cfg(unix)] -async fn test_run_tool_command_renders_null_args_as_valid_json() { - use jp_config::conversation::tool::CommandConfig; - - let ctx = json!({ - "tool": { - "name": "cargo_test", - "arguments": { - "package": "jp_workspace", - "backtrace": null, - "testname": null, - }, - "answers": {}, - "options": {}, - }, - "context": { - "action": "run", - "root": "/tmp", - }, - }); - - let command = CommandConfig { - program: "echo".to_owned(), - args: vec!["{{tool}}".to_owned()], - shell: false, - }; - - let result = run_tool_command(command, ctx, "/tmp".into(), CancellationToken::new(), None) - .await - .unwrap(); - - let stdout = match result { - CommandResult::RawOutput { stdout, .. } => stdout, - other => panic!("Expected RawOutput, got: {other:?}"), - }; - - // The rendered output must be valid JSON with proper `null` values. - let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { - panic!("run_tool_command produced invalid JSON: {e}\n\nOutput: {stdout}") - }); - - assert_eq!(parsed["arguments"]["package"], "jp_workspace"); - assert_eq!(parsed["arguments"]["backtrace"], Value::Null); - assert_eq!(parsed["name"], "cargo_test"); -} - -/// Regression: scalar string interpolation must not be JSON-quoted. -/// A prior fix for the null-rendering bug set `AutoEscape::Json` globally, -/// which wrapped every string value in literal `"..."`, breaking templates like -/// `just rfd-draft {{tool.arguments.title}}` where tool authors expect the bare -/// value. -#[tokio::test] -#[cfg(unix)] -async fn test_run_tool_command_renders_scalar_strings_raw() { - use jp_config::conversation::tool::CommandConfig; - - let ctx = json!({ - "tool": { - "arguments": { "title": "Hello World" }, - }, - }); - - let command = CommandConfig { - program: "echo".to_owned(), - args: vec!["{{tool.arguments.title}}".to_owned()], - shell: false, - }; - - let result = run_tool_command(command, ctx, "/tmp".into(), CancellationToken::new(), None) - .await - .unwrap(); - - let stdout = match result { - CommandResult::RawOutput { stdout, .. } => stdout, - other => panic!("Expected RawOutput, got: {other:?}"), - }; - - assert_eq!(stdout.trim_end(), "Hello World"); -} - -/// Null scalars render as literal `null` (not Jinja2's `none`, and not an empty -/// string). -/// This keeps the behavior consistent with how null appears inside -/// JSON-serialized composites. -#[tokio::test] -#[cfg(unix)] -async fn test_run_tool_command_renders_null_scalar_as_literal_null() { - use jp_config::conversation::tool::CommandConfig; - - let ctx = json!({ - "tool": { "arguments": { "maybe": null } }, - }); - - let command = CommandConfig { - program: "echo".to_owned(), - args: vec!["{{tool.arguments.maybe}}".to_owned()], - shell: false, - }; - - let result = run_tool_command(command, ctx, "/tmp".into(), CancellationToken::new(), None) - .await - .unwrap(); - - let stdout = match result { - CommandResult::RawOutput { stdout, .. } => stdout, - other => panic!("Expected RawOutput, got: {other:?}"), - }; - - assert_eq!(stdout.trim_end(), "null"); -} - -/// End-to-end sanity check for the rfd-draft regression: with the old -/// `AutoEscape::Json` behavior, `{{tool.arguments.title}}` rendered as -/// `"Assistant-Initiated ..."` (literal quotes), which then broke the -/// downstream `sed` command inside the just recipe. -/// Verify the title now reaches the subprocess as a clean argument. -#[tokio::test] -#[cfg(unix)] -async fn test_run_tool_command_rfd_draft_title_rendering() { - use jp_config::conversation::tool::CommandConfig; - - let ctx = json!({ - "tool": { - "arguments": { - "variant": "design", - "title": "Assistant-Initiated User Inquiries via an ask_user Builtin", - }, - }, - }); - - // Mimic the real `just rfd-draft {{variant}} {{title}}` template. - let command = CommandConfig { - program: "printf".to_owned(), - args: vec![ - "%s|%s".to_owned(), - "{{tool.arguments.variant}}".to_owned(), - "{{tool.arguments.title}}".to_owned(), - ], - shell: false, - }; - - let result = run_tool_command(command, ctx, "/tmp".into(), CancellationToken::new(), None) - .await - .unwrap(); - - let stdout = match result { - CommandResult::RawOutput { stdout, .. } => stdout, - other => panic!("Expected RawOutput, got: {other:?}"), - }; - - assert_eq!( - stdout, - "design|Assistant-Initiated User Inquiries via an ask_user Builtin" - ); -} - -/// The `tojson` filter still works for tool authors who want explicit -/// JSON-quoted strings (e.g. when hand-crafting a JSON literal). -/// Safe strings produced by `tojson` must pass through the custom formatter -/// unchanged — no double-encoding. -#[tokio::test] -#[cfg(unix)] -async fn test_run_tool_command_tojson_filter_on_scalar_still_works() { - use jp_config::conversation::tool::CommandConfig; - - let ctx = json!({ - "tool": { "arguments": { "title": "Hello" } }, - }); - - let command = CommandConfig { - program: "echo".to_owned(), - args: vec!["{{tool.arguments.title | tojson}}".to_owned()], - shell: false, - }; - - let result = run_tool_command(command, ctx, "/tmp".into(), CancellationToken::new(), None) - .await - .unwrap(); - - let stdout = match result { - CommandResult::RawOutput { stdout, .. } => stdout, - other => panic!("Expected RawOutput, got: {other:?}"), - }; - - assert_eq!(stdout.trim_end(), "\"Hello\""); -} - -/// Regression: the `run` path must surface the invocation's workspace and -/// conversation IDs to the tool command via `context.workspace_id` and -/// `context.conversation_id`. -/// A non-empty `InvocationContext` pins the wiring so the fields can't be -/// silently dropped or emptied. -#[tokio::test] -#[cfg(unix)] -async fn test_execute_local_exposes_invocation_ids_in_context() { - use jp_config::{ - AppConfig, Config, - conversation::tool::{PartialToolConfig, ToolConfig}, - }; - - let partial: PartialToolConfig = serde_json::from_value(json!({ - "source": "local", - "command": "echo {{context.workspace_id}}-{{context.conversation_id}}", - })) - .expect("valid partial tool config"); - let tool = ToolConfig::from_partial(partial, vec![]).expect("resolved tool config"); - - let mut cfg = AppConfig::new_test(); - cfg.conversation.tools.insert("echo_ids".to_owned(), tool); - let config = cfg - .conversation - .tools - .get("echo_ids") - .expect("tool present"); - - let definition = ToolDefinition { - name: "echo_ids".to_owned(), - docs: ToolDocs::default(), - parameters: schema([]), - }; - let invocation = InvocationContext { - workspace_id: "ws-abc".to_owned(), - conversation_id: "conv-xyz".to_owned(), - }; - let mcp_client = Client::new(IndexMap::new()); - let builtins = builtin::BuiltinExecutors::new(); - - let outcome = definition - .execute( - "call-1".to_owned(), - json!({}), - &IndexMap::new(), - &config, - &mcp_client, - Utf8Path::new("/tmp"), - CancellationToken::new(), - &builtins, - None, - &invocation, - None, - ) - .await - .expect("execution succeeds"); - - match outcome { - ExecutionOutcome::Completed { - result: Ok(out), .. - } => assert!( - out.contains("ws-abc-conv-xyz"), - "expected workspace/conversation IDs in tool output, got: {out:?}" - ), - other => panic!("expected completed success, got: {other:?}"), - } -} - -/// A built-in that reports it ran, so dispatch can be observed. -struct ReachedBuiltin; - -#[async_trait::async_trait] -impl builtin::BuiltinTool for ReachedBuiltin { - async fn execute(&self, _: &Value, _: &IndexMap) -> jp_tool::Outcome { - "reached".into() - } -} - -/// A built-in tool may be keyed differently from the implementation it names: -/// `source = "builtin.describe_tools"` under a `docs` key. -/// Dispatch keys on the source's tool name, matching how the local and MCP -/// paths treat theirs. -#[tokio::test] -async fn test_execute_builtin_dispatches_on_source_name() { - use jp_config::{ - AppConfig, Config, - conversation::tool::{PartialToolConfig, ToolConfig}, - }; - - let partial: PartialToolConfig = serde_json::from_value(json!({ - "source": "builtin.describe_tools", - })) - .expect("valid partial tool config"); - let tool = ToolConfig::from_partial(partial, vec![]).expect("resolved tool config"); - - let mut cfg = AppConfig::new_test(); - cfg.conversation.tools.insert("docs".to_owned(), tool); - let config = cfg.conversation.tools.get("docs").expect("tool present"); - - let definition = ToolDefinition { - name: "docs".to_owned(), - docs: ToolDocs::default(), - parameters: schema([]), - }; - let mcp_client = Client::new(IndexMap::new()); - let builtins = builtin::BuiltinExecutors::new().register("describe_tools", ReachedBuiltin); - - let outcome = definition - .execute( - "call-1".to_owned(), - json!({}), - &IndexMap::new(), - &config, - &mcp_client, - Utf8Path::new("/tmp"), - CancellationToken::new(), - &builtins, - None, - &InvocationContext::default(), - None, - ) - .await - .expect("execution succeeds"); - - match outcome { - ExecutionOutcome::Completed { - result: Ok(out), .. - } => assert_eq!(out, "reached"), - other => panic!("expected completed success, got: {other:?}"), - } -} - -/// Regression for RFD 081: `tool_definitions` keeps a *forced* tool that is -/// merely disabled (`OFF`), but always drops a locked-off tool (`state = -/// false`, `allow_toggle = never`) even when it is forced. -#[tokio::test] -async fn test_tool_definitions_forced_tool_drops_locked_off() { - use jp_config::{ - AppConfig, Config, - conversation::tool::{PartialToolConfig, ToolConfig}, - }; - - let off: PartialToolConfig = serde_json::from_value(json!({ - "source": "local", - "command": "echo off", - "enable": false, - })) - .expect("valid partial tool config"); - let locked_off: PartialToolConfig = serde_json::from_value(json!({ - "source": "local", - "command": "echo locked", - "enable": { "state": false, "allow_toggle": "never" }, - })) - .expect("valid partial tool config"); - - let mut cfg = AppConfig::new_test(); - cfg.conversation.tools.insert( - "off_tool".to_owned(), - ToolConfig::from_partial(off, vec![]).expect("resolved tool config"), - ); - cfg.conversation.tools.insert( - "locked_off_tool".to_owned(), - ToolConfig::from_partial(locked_off, vec![]).expect("resolved tool config"), - ); - - let mcp_client = Client::new(IndexMap::new()); - - // Forcing the toggleable OFF tool keeps it in the definitions. - let defs = tool_definitions(cfg.conversation.tools.iter(), &mcp_client, Some("off_tool")) - .await - .expect("tool definitions resolve"); - assert!( - defs.iter().any(|d| d.name == "off_tool"), - "a forced toggleable OFF tool must be kept" - ); - - // Forcing the locked-off tool still drops it. - let defs = tool_definitions( - cfg.conversation.tools.iter(), - &mcp_client, - Some("locked_off_tool"), - ) - .await - .expect("tool definitions resolve"); - assert!( - !defs.iter().any(|d| d.name == "locked_off_tool"), - "a locked-off tool must be dropped even when forced" - ); -} - -/// A tool whose schema cannot be resolved is dropped from the request rather -/// than failing the whole query, mirroring how an unavailable MCP server is -/// handled. -#[tokio::test] -async fn tool_with_an_unresolvable_schema_is_skipped() { - let broken: PartialToolConfig = serde_json::from_value(json!({ - "source": "local", - "command": "echo broken", - "parameters": { "tags": { "type": "array" } }, - })) - .expect("valid partial tool config"); - let healthy: PartialToolConfig = serde_json::from_value(json!({ - "source": "local", - "command": "echo fine", - "parameters": { "path": { "type": "string" } }, - })) - .expect("valid partial tool config"); - - let mut cfg = AppConfig::new_test(); - cfg.conversation.tools.insert( - "broken_tool".to_owned(), - ToolConfig::from_partial(broken, vec![]).expect("resolved tool config"), - ); - cfg.conversation.tools.insert( - "healthy_tool".to_owned(), - ToolConfig::from_partial(healthy, vec![]).expect("resolved tool config"), - ); - - let defs = tool_definitions( - cfg.conversation.tools.iter(), - &Client::new(IndexMap::new()), - None, - ) - .await - .expect("a broken tool must not fail the query"); - - let names = defs.iter().map(|d| d.name.as_str()).collect::>(); - assert_eq!(names, vec!["healthy_tool"]); -} - -/// Naming a tool with `--tool` is an explicit request for it, so its schema -/// error surfaces instead of the tool silently disappearing. -#[tokio::test] -async fn forced_tool_with_an_unresolvable_schema_still_errors() { - let broken: PartialToolConfig = serde_json::from_value(json!({ - "source": "local", - "command": "echo broken", - "parameters": { "tags": { "type": "array" } }, - })) - .expect("valid partial tool config"); - - let mut cfg = AppConfig::new_test(); - cfg.conversation.tools.insert( - "broken_tool".to_owned(), - ToolConfig::from_partial(broken, vec![]).expect("resolved tool config"), - ); - - let error = tool_definitions( - cfg.conversation.tools.iter(), - &Client::new(IndexMap::new()), - Some("broken_tool"), - ) - .await - .unwrap_err(); - - assert_eq!( - error.to_string(), - "Invalid schema at `conversation.tools.broken_tool.parameters.tags.items`: array schemas \ - must declare an item schema" - ); -} diff --git a/crates/jp_llm/src/window.rs b/crates/jp_llm/src/window.rs index 014fcf80c..e36c92acf 100644 --- a/crates/jp_llm/src/window.rs +++ b/crates/jp_llm/src/window.rs @@ -14,10 +14,9 @@ use jp_attachment::Attachment; use jp_config::assistant::sections::SectionConfig; use jp_conversation::{ConversationEvent, ConversationStream, EventKind, event::ChatResponse}; +use jp_tool::ToolDefinition; use tracing::info; -use crate::tool::ToolDefinition; - /// Estimated chars-per-token ratio used for estimation. /// /// Measured against a real Anthropic request: a 4,220,150-byte serialized body diff --git a/crates/jp_llm/src/window_tests.rs b/crates/jp_llm/src/window_tests.rs index 82fccb528..f0a9f9b32 100644 --- a/crates/jp_llm/src/window_tests.rs +++ b/crates/jp_llm/src/window_tests.rs @@ -1,8 +1,8 @@ use jp_config::{PartialAppConfig, assistant::request::CachePolicy}; use jp_conversation::{Compaction, ConversationStream, SummaryPolicy, event::ChatResponse}; +use jp_tool::ToolDocs; use super::*; -use crate::tool::ToolDocs; fn tool(name: &str, summary: Option<&str>) -> ToolDefinition { ToolDefinition { diff --git a/crates/jp_llm/tests/fixtures/acp/live.jsonl b/crates/jp_llm/tests/fixtures/acp/live.jsonl new file mode 100644 index 000000000..425871877 --- /dev/null +++ b/crates/jp_llm/tests/fixtures/acp/live.jsonl @@ -0,0 +1,123 @@ +{"connection":0,"from":"jp","message":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"_meta":{"claudeCode":{"promptQueueing":true},"authStatus":{}},"promptCapabilities":{"image":true,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":true},"auth":{"logout":{}},"providers":{},"loadSession":true,"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"0.76.0"},"authMethods":[],"_meta":{"jetbrains":{"air":{"version":1,"capabilities":["sessionFailure","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue"]}},"steering":{"supported":true},"goal":{"version":1,"controlMethod":"_session/goal","actions":["set","clear"]}}}}} +{"connection":0,"from":"jp","message":{"jsonrpc":"2.0","id":2,"method":"session/load","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","cwd":"[redacted]","mcpServers":[],"_meta":{"claudeCode":{"emitRawSDKMessages":true,"options":{"systemPrompt":{"type":"custom","prompt":"Qualification 2fc93839-f236-4058-b7f5-bf774840db8f. Use the supplied invoice history.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\n","snapshot":false},"model":"claude-opus-5","thinking":{"type":"disabled"},"tools":[],"allowedTools":[],"strictMcpConfig":true,"settingSources":[],"settings":{"disableAllHooks":true,"autoMemoryEnabled":false,"permissions":{"ask":["mcp__jp__*"]}},"persistSession":true,"env":{"CLAUDE_CODE_DISABLE_AUTO_MEMORY":"1","CLAUDE_CODE_DISABLE_BACKGROUND_TASKS":"1","CLAUDE_CODE_MAX_OUTPUT_TOKENS":"128","CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS":"0","DISABLE_AUTO_COMPACT":"1","ENABLE_TOOL_SEARCH":"false","MAX_MCP_OUTPUT_TOKENS":"100000"}}}}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"_auth/status_update","params":{"authStatus":{"kind":"account","label":"Claude Max","account":{"plan":"max","email":"[redacted]","organization":"[redacted]"}}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"_auth/status_update","params":{"authStatus":{"kind":"account","label":"Claude Max","account":{"plan":"Claude Max","email":"[redacted]","organization":"[redacted]"}}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"The active invoice is INV-1042."},"messageId":"8a7bce86-6c50-57b8-8d67-fa66066cd7dd"}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Acknowledged."},"messageId":"msg_jp_951aa2f3985b5d38a8790d481c6b16f4"}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","id":2,"result":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","modes":{"currentModeId":"default","availableModes":[{"id":"default","name":"Manual","description":"Always ask before making changes","_meta":{"kind":"standard"}},{"id":"acceptEdits","name":"Accept edits","description":"Automatically accept all file edits","_meta":{"kind":"standard"}},{"id":"plan","name":"Plan","description":"Create a plan before making changes","_meta":{"kind":"plan"}},{"id":"auto","name":"Auto","description":"Claude handles permission decisions","_meta":{"kind":"auto_review"}},{"id":"bypassPermissions","name":"Bypass permissions","description":"Accepts all permissions","_meta":{"kind":"full_access"}}]},"configOptions":[{"id":"mode","name":"Mode","description":"Session permission mode","category":"mode","type":"select","currentValue":"default","options":[{"value":"default","name":"Manual","description":"Always ask before making changes","_meta":{"kind":"standard"}},{"value":"acceptEdits","name":"Accept edits","description":"Automatically accept all file edits","_meta":{"kind":"standard"}},{"value":"plan","name":"Plan","description":"Create a plan before making changes","_meta":{"kind":"plan"}},{"value":"auto","name":"Auto","description":"Claude handles permission decisions","_meta":{"kind":"auto_review"}},{"value":"bypassPermissions","name":"Bypass permissions","description":"Accepts all permissions","_meta":{"kind":"full_access"}}]},{"id":"model","name":"Model","description":"AI model to use","category":"model","type":"select","currentValue":"default","options":[{"value":"default","name":"Default (recommended)","description":"Opus (1M context)"},{"value":"opus[1m]","name":"Opus 5","description":"Opus 5 with 1M context · Best for everyday, complex tasks"},{"value":"claude-fable-5-1[1m]","name":"Fable 5.1","description":"Fable 5.1 · Most capable for your hardest and longest-running tasks"},{"value":"sonnet","name":"Sonnet 5","description":"Sonnet 5 · Efficient for routine tasks"},{"value":"haiku","name":"Haiku 4.5","description":"Haiku 4.5 · Fastest for quick answers"}]},{"id":"effort","name":"Effort","description":"Available effort levels for this model","category":"thought_level","type":"select","currentValue":"default","options":[{"value":"default","name":"Default"},{"value":"low","name":"Low"},{"value":"medium","name":"Medium"},{"value":"high","name":"High"},{"value":"xhigh","name":"Xhigh"},{"value":"max","name":"Max"}]},{"id":"fast","name":"Fast mode","description":"Faster responses on supported models","category":"model_config","type":"select","currentValue":"off","options":[{"value":"on","name":"On"},{"value":"off","name":"Off"}]}]}}} +{"connection":0,"from":"jp","message":{"jsonrpc":"2.0","id":3,"method":"session/set_config_option","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","configId":"model","value":"claude-opus-5"}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"deep-research","description":"Deep research harness — fan-out web searches, fetch sources, adversarially verify claims, synthesize a cited report. (dynamic workflow)","input":null},{"name":"design-sync","description":"Push a React design system to claude.ai/design. This runs a converter that bundles the real component code (from Storybook or a bare package) and uploads it. Use when the user runs /design-sync or says \"sync my design system to Claude Design\".","input":{"hint":"[]"}},{"name":"dataviz","description":"Use this skill whenever you are about to create ANY chart, graph, plot, dashboard, or data visualization, in ANY output medium — an HTML or React artifact, inline SVG, plotting code in any library (matplotlib, plotly, d3, Recharts, …), an image/PNG you will render and upload, or a chart shared into Slack. Read it BEFORE writing the first line of chart code, choosing chart colors, building a stat tile / meter / KPI row, or laying out a dashboard. When the destination is a first-party document connector (host-designated, never self-described) that renders live charts, hand it the rows (inline, or as an uploaded data file the chart cites) rather than a rendered PNG/SVG — a picture of a chart loses hover, data inspection and per-value comments. Produces visualizations that read as one system — elegant, accessible, consistent in light and dark — using a brand-neutral placeholder palette you swap for your own. Teaches a design-system-agnostic method: a form heuristic, a color formula with a runnable validator, mark specs, and interaction rules. A validated default palette is documented in `references/palette.md` — swap that file's values for your brand's. Triggers on: \"chart\", \"graph\", \"plot\", \"data viz\", \"visualization\", \"dashboard\", \"analytics\", \"visualize data\", \"categorical colors\", \"sequential / diverging palette\", \"stat tile\", \"sparkline\", \"heatmap\", \"legend\", \"axis\", \"tooltip\", \"chart colors\", \"color by series\".","input":null},{"name":"update-config","description":"Use this skill to configure the Claude Code harness via settings.json. Automated behaviors (\"from now on when X\", \"each time X\", \"whenever X\", \"before/after X\") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions (\"allow X\", \"add permission\", \"move permission to\"), env vars (\"set X=Y\"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: \"allow npm commands\", \"add bq permission to global settings\", \"move permission to user settings\", \"set DEBUG=true\", \"when claude stops show X\". For simple settings like theme/model, suggest the /config command.","input":null},{"name":"verify","description":"Verify that a code change actually does what it's supposed to by exercising it end-to-end and observing behavior — drive the affected flow, not just tests or typecheck. Run before committing nontrivial changes; bootstraps this repo's project verify skill if none exists yet. Don't invoke it on a diff that only touches tests, docs, or other code with no runtime surface to drive (a change to product source always has one) — there's nothing to observe.","input":null},{"name":"debug","description":"Enable debug logging for this session and help diagnose issues","input":{"hint":"[issue description]"}},{"name":"code-review","description":"Review the current diff, or a PR number/branch/path target, for correctness bugs and reuse/simplification/efficiency cleanups at the given effort level (low/medium: fewer, high-confidence findings; high→max: broader coverage, may include uncertain findings; ultra: deep multi-agent review in the cloud); with no level given, it reuses the level you typed last. Pass --comment to post findings as inline PR comments, or --fix to apply the findings to the working tree after the review. For ultra on a GitHub.com PR target, --post asks to post the finished review’s findings to the PR as a single comment from the user’s GitHub account (not a review; the launch dialog still confirms in interactive sessions, while non-interactive mode posts on the flag alone) and --no-post hides that option.","input":{"hint":"[low|medium|high|xhigh|max|ultra] [--fix] [--comment] [||]"}},{"name":"simplify","description":"Review the changed code for reuse, simplification, efficiency, and altitude cleanups, then apply the fixes. Quality only — it does not hunt for bugs; use /code-review for that.","input":{"hint":"[]"}},{"name":"batch","description":"Research and plan a large-scale change, then execute it in parallel across 5–30 isolated worktree agents that each open a PR.","input":{"hint":""}},{"name":"fewer-permission-prompts","description":"Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.","input":null},{"name":"doctor","description":"Health-check the user's Claude Code setup and fix issues: diagnose installation health — what the `claude doctor` terminal diagnostics cover — from local data (duplicate or leftover installs, PATH, unparseable settings files, broken or colliding agent definitions, skills whose frontmatter fails to parse); find unused skills, MCP servers, and plugins versus their context cost and disable dead weight; deduplicate local CLAUDE.md files against checked-in ones; trim checked-in CLAUDE.md files by cutting content a session could derive from the codebase (directory layouts, tech-stack lists, architecture overviews) while keeping gotchas, rationale, and non-standard conventions; migrate always-loaded CLAUDE.md guidance into lazy skills and nested CLAUDE.md files; flag slow hooks and context-heavy extensions; check the installed version is current; make auto mode the default permission mode; and pre-approve frequently denied read-only commands. Use when the user asks for a doctor run, checkup, audit, tune-up, or cleanup of their Claude Code setup or configuration.","input":null},{"name":"loop","description":"Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo). Omit the interval to let the model self-pace.","input":{"hint":"[interval] [prompt]"}},{"name":"schedule","description":"Create, update, list, or run scheduled cloud agents (routines) that execute on a cron schedule.","input":null},{"name":"claude-api","description":"Reference for the Claude API / Anthropic SDK — model ids, pricing, params, streaming, tool use, MCP, agents, caching, token counting, model migration.\nTRIGGER — read BEFORE opening the target file; don't skip because it \"looks like a one-liner\" — whenever: the prompt names Claude/Anthropic in any form (Claude, Anthropic, Fable, Opus, Sonnet, Haiku, `anthropic`, `@anthropic-ai`, `claude-*`, `us.anthropic.*`, `[1m]`); the user asks about an LLM (pricing/model choice/limits/caching) — never answer from memory; OR the task is LLM-shaped with provider unstated (agent/MCP/tool-definition/multi-agent/RAG/LLM-judge/computer-use; generate/summarize/extract/classify/rewrite/converse over NL; debugging refusals/cutoffs/streaming/tool-calls/tokens).\nSKIP only when another provider is being worked on (overrides all triggers): OpenAI/GPT/Gemini/Llama/Mistral/Cohere/Ollama named in the query; OR `grep -rE 'openai|langchain_openai|google.generativeai|genai|mistralai|cohere|ollama'` over the project hits (run this grep FIRST if no provider named — don't Read the file).","input":null},{"name":"workflow-authoring","description":"Reference for writing a Workflow tool script (script API and gotchas, resume, quality patterns, worked examples). Load before authoring a script for a workflow the user already opted into; it does not itself authorize running one.","input":null},{"name":"run","description":"Launch and drive this project's app to see a change working. Use when asked to run, start, or screenshot the app, or to confirm a change works in the real app (not just tests). First looks for a project skill that already covers launching the app; otherwise falls back to built-in patterns per project type (CLI, server, TUI, Electron, browser-driven, library).","input":null},{"name":"run-skill-generator","description":"Author or improve the run- skill - a per-project skill that tells agents how to build, launch, and drive this project's app. Use when the user asks to set up the project, get it running, write run instructions, or verify build/run steps work from a clean environment.","input":null},{"name":"agents","description":"(removed) Ask Claude to create/manage subagents, or edit .claude/agents/","input":null},{"name":"auto-mode-setup","description":"Teach auto mode about your environment, plus optional rule tweaks","input":{"hint":"[--request-id ] (--wizard posture=… scope=… depth=… --propose | --expect-sha256 <64-hex> --apply-file )"}},{"name":"autocompact","description":"Configure the auto-compact window size","input":{"hint":"[auto|]"}},{"name":"color","description":"Set the prompt bar color for this session","input":{"hint":"[red|blue|green|yellow|purple|orange|pink|cyan|default]"}},{"name":"compact","description":"Free up context by summarizing the conversation so far","input":{"hint":""}},{"name":"config","description":"Set a setting by key","input":{"hint":"key=value"}},{"name":"context","description":"Show current context usage","input":null},{"name":"effort","description":"Set effort level for model usage","input":{"hint":""}},{"name":"fast","description":"Toggle fast mode (Opus 5)","input":{"hint":"[on|off]"}},{"name":"heapdump","description":"Dump the JS heap to ~/Desktop","input":null},{"name":"init","description":"Initialize a new CLAUDE.md file with codebase documentation","input":null},{"name":"mcp","description":"Manage MCP servers","input":{"hint":"[reconnect|enable|disable [|all]]"}},{"name":"import","description":"Import config from another AI coding agent","input":null},{"name":"model","description":"Set the AI model for Claude Code","input":{"hint":""}},{"name":"__remote-workflow","description":"Run the workflow script delivered in this session environment (server-launched sessions only)","input":null},{"name":"workflow-launch-exec","description":"Execute a server-launched workflow handoff (workflow_launch event sessions only)","input":null},{"name":"reload-skills","description":"Pick up skills added or changed on disk during this session","input":null},{"name":"rename","description":"Rename the current conversation","input":{"hint":"[name]"}},{"name":"ultrareview","description":"Start a cloud agent that finds and verifies bugs in your branch (~5-10 min, $5-$25 USD) · Runs in Claude Code on the web. See https://code.claude.com/docs/en/claude-code-on-the-web","input":null},{"name":"security-review","description":"Complete a security review of the pending changes on the current branch","input":null},{"name":"usage-credits","description":"Configure usage credits or request them from your admin when you hit a limit","input":null},{"name":"extra-usage","description":"Renamed to /usage-credits","input":null},{"name":"usage","description":"Show session cost, plan usage, and what's contributing to your limits","input":null},{"name":"insights","description":"Generate a report analyzing your Claude Code sessions","input":null},{"name":"recap","description":"Generate a one-line session recap now","input":null},{"name":"skill-doctor","description":"Show which loaded skills are unused and costing context","input":null},{"name":"goal","description":"Set a goal — keep working until the condition is met","input":null},{"name":"design","description":"Grant or revoke Claude agent access to your Design projects","input":{"hint":"consent | revoke"}},{"name":"design-consent","description":"Grant Claude agent access to your Design projects","input":null},{"name":"design-revoke","description":"Revoke Claude agent access to your Design projects","input":null},{"name":"list-agents","description":"List subagents, teammates, and other Claude sessions you can message","input":null},{"name":"team-onboarding","description":"Help teammates ramp on Claude Code with a guide from your usage","input":null}]}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"mode","name":"Mode","description":"Session permission mode","category":"mode","type":"select","currentValue":"default","options":[{"value":"default","name":"Manual","description":"Always ask before making changes","_meta":{"kind":"standard"}},{"value":"acceptEdits","name":"Accept edits","description":"Automatically accept all file edits","_meta":{"kind":"standard"}},{"value":"plan","name":"Plan","description":"Create a plan before making changes","_meta":{"kind":"plan"}},{"value":"auto","name":"Auto","description":"Claude handles permission decisions","_meta":{"kind":"auto_review"}},{"value":"bypassPermissions","name":"Bypass permissions","description":"Accepts all permissions","_meta":{"kind":"full_access"}}]},{"id":"model","name":"Model","description":"AI model to use","category":"model","type":"select","currentValue":"opus[1m]","options":[{"value":"default","name":"Default (recommended)","description":"Opus (1M context)"},{"value":"opus[1m]","name":"Opus 5","description":"Opus 5 with 1M context · Best for everyday, complex tasks"},{"value":"claude-fable-5-1[1m]","name":"Fable 5.1","description":"Fable 5.1 · Most capable for your hardest and longest-running tasks"},{"value":"sonnet","name":"Sonnet 5","description":"Sonnet 5 · Efficient for routine tasks"},{"value":"haiku","name":"Haiku 4.5","description":"Haiku 4.5 · Fastest for quick answers"}]},{"id":"effort","name":"Effort","description":"Available effort levels for this model","category":"thought_level","type":"select","currentValue":"default","options":[{"value":"default","name":"Default"},{"value":"low","name":"Low"},{"value":"medium","name":"Medium"},{"value":"high","name":"High"},{"value":"xhigh","name":"Xhigh"},{"value":"max","name":"Max"}]},{"id":"fast","name":"Fast mode","description":"Faster responses on supported models","category":"model_config","type":"select","currentValue":"off","options":[{"value":"on","name":"On"},{"value":"off","name":"Off"}]}]}}} +{"connection":0,"from":"jp","message":{"jsonrpc":"2.0","id":4,"method":"session/set_config_option","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","configId":"mode","value":"default"}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","update":{"sessionUpdate":"current_mode_update","currentModeId":"default"}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"mode","name":"Mode","description":"Session permission mode","category":"mode","type":"select","currentValue":"default","options":[{"value":"default","name":"Manual","description":"Always ask before making changes","_meta":{"kind":"standard"}},{"value":"acceptEdits","name":"Accept edits","description":"Automatically accept all file edits","_meta":{"kind":"standard"}},{"value":"plan","name":"Plan","description":"Create a plan before making changes","_meta":{"kind":"plan"}},{"value":"auto","name":"Auto","description":"Claude handles permission decisions","_meta":{"kind":"auto_review"}},{"value":"bypassPermissions","name":"Bypass permissions","description":"Accepts all permissions","_meta":{"kind":"full_access"}}]},{"id":"model","name":"Model","description":"AI model to use","category":"model","type":"select","currentValue":"opus[1m]","options":[{"value":"default","name":"Default (recommended)","description":"Opus (1M context)"},{"value":"opus[1m]","name":"Opus 5","description":"Opus 5 with 1M context · Best for everyday, complex tasks"},{"value":"claude-fable-5-1[1m]","name":"Fable 5.1","description":"Fable 5.1 · Most capable for your hardest and longest-running tasks"},{"value":"sonnet","name":"Sonnet 5","description":"Sonnet 5 · Efficient for routine tasks"},{"value":"haiku","name":"Haiku 4.5","description":"Haiku 4.5 · Fastest for quick answers"}]},{"id":"effort","name":"Effort","description":"Available effort levels for this model","category":"thought_level","type":"select","currentValue":"default","options":[{"value":"default","name":"Default"},{"value":"low","name":"Low"},{"value":"medium","name":"Medium"},{"value":"high","name":"High"},{"value":"xhigh","name":"Xhigh"},{"value":"max","name":"Max"}]},{"id":"fast","name":"Fast mode","description":"Faster responses on supported models","category":"model_config","type":"select","currentValue":"off","options":[{"value":"on","name":"On"},{"value":"off","name":"Off"}]}]}}} +{"connection":0,"from":"jp","message":{"jsonrpc":"2.0","id":5,"method":"session/prompt","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","prompt":[{"type":"text","text":"Return only the active invoice ID."}]}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","message":{"type":"user","message":{"role":"user","content":"Set model to `opus[1m] (claude-opus-5[1m])`"},"session_id":"09165489-8be4-4ff7-a413-da1db464a44f","parent_tool_use_id":null,"uuid":"08128702-b14c-4c35-ba84-a2ccaf86f48d","timestamp":"2026-09-15T21:39:07.529Z","isReplay":true}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","message":{"type":"system","subtype":"session_state_changed","state":"running","uuid":"a8e1a205-7670-4ba9-ad1f-eb48f934d925","session_id":"09165489-8be4-4ff7-a413-da1db464a44f"}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","message":{"type":"command_lifecycle","command_uuid":"cdf79335-2c6a-418b-a45f-5cf0a050d8c8","state":"queued","uuid":"adeb0419-5a5a-40e5-9a35-8f6177d2943c","session_id":"09165489-8be4-4ff7-a413-da1db464a44f"}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","message":{"type":"command_lifecycle","command_uuid":"cdf79335-2c6a-418b-a45f-5cf0a050d8c8","state":"started","uuid":"efc6b609-c644-4795-9b90-3e01a33b0920","session_id":"09165489-8be4-4ff7-a413-da1db464a44f"}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","message":{"type":"system","subtype":"init","cwd":"[redacted]","session_id":"09165489-8be4-4ff7-a413-da1db464a44f","tools":[],"mcp_servers":[],"model":"claude-opus-5[1m]","permissionMode":"default","slash_commands":["deep-research","design-sync","dataviz","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","doctor","loop","schedule","claude-api","workflow-authoring","run","run-skill-generator","agents","auto-mode-setup","autocompact","clear","color","compact","config","context","effort","fast","heapdump","init","mcp","import","model","__remote-workflow","workflow-launch-exec","reload-skills","rename","ultrareview","security-review","usage-credits","extra-usage","usage","insights","recap","skill-doctor","goal","design","design-consent","design-revoke","list-agents","team-onboarding"],"terminal_slash_commands":["doctor","color"],"apiKeySource":"none","claude_code_version":"2.1.257","output_style":"default","agents":["claude","Explore","general-purpose","Plan","statusline-setup"],"skills":["deep-research","design-sync","dataviz","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","doctor","loop","schedule","claude-api","workflow-authoring","run","run-skill-generator"],"plugins":[],"capabilities":["interrupt_receipt_v1","interrupt_cancel_queued_v1","msg_lifecycle_v1"],"analytics_disabled":false,"product_feedback_disabled":false,"uuid":"d8fef39e-2071-4c13-bb57-4b4a1964a5b6","messaging_socket_path":"/tmp/cc-socks/7658.sock","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required"}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"deep-research","description":"Deep research harness — fan-out web searches, fetch sources, adversarially verify claims, synthesize a cited report. (dynamic workflow)","input":null},{"name":"design-sync","description":"Push a React design system to claude.ai/design. This runs a converter that bundles the real component code (from Storybook or a bare package) and uploads it. Use when the user runs /design-sync or says \"sync my design system to Claude Design\".","input":{"hint":"[]"}},{"name":"dataviz","description":"Use this skill whenever you are about to create ANY chart, graph, plot, dashboard, or data visualization, in ANY output medium — an HTML or React artifact, inline SVG, plotting code in any library (matplotlib, plotly, d3, Recharts, …), an image/PNG you will render and upload, or a chart shared into Slack. Read it BEFORE writing the first line of chart code, choosing chart colors, building a stat tile / meter / KPI row, or laying out a dashboard. When the destination is a first-party document connector (host-designated, never self-described) that renders live charts, hand it the rows (inline, or as an uploaded data file the chart cites) rather than a rendered PNG/SVG — a picture of a chart loses hover, data inspection and per-value comments. Produces visualizations that read as one system — elegant, accessible, consistent in light and dark — using a brand-neutral placeholder palette you swap for your own. Teaches a design-system-agnostic method: a form heuristic, a color formula with a runnable validator, mark specs, and interaction rules. A validated default palette is documented in `references/palette.md` — swap that file's values for your brand's. Triggers on: \"chart\", \"graph\", \"plot\", \"data viz\", \"visualization\", \"dashboard\", \"analytics\", \"visualize data\", \"categorical colors\", \"sequential / diverging palette\", \"stat tile\", \"sparkline\", \"heatmap\", \"legend\", \"axis\", \"tooltip\", \"chart colors\", \"color by series\".","input":null},{"name":"update-config","description":"Use this skill to configure the Claude Code harness via settings.json. Automated behaviors (\"from now on when X\", \"each time X\", \"whenever X\", \"before/after X\") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions (\"allow X\", \"add permission\", \"move permission to\"), env vars (\"set X=Y\"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: \"allow npm commands\", \"add bq permission to global settings\", \"move permission to user settings\", \"set DEBUG=true\", \"when claude stops show X\". For simple settings like theme/model, suggest the /config command.","input":null},{"name":"verify","description":"Verify that a code change actually does what it's supposed to by exercising it end-to-end and observing behavior — drive the affected flow, not just tests or typecheck. Run before committing nontrivial changes; bootstraps this repo's project verify skill if none exists yet. Don't invoke it on a diff that only touches tests, docs, or other code with no runtime surface to drive (a change to product source always has one) — there's nothing to observe.","input":null},{"name":"debug","description":"Enable debug logging for this session and help diagnose issues","input":{"hint":"[issue description]"}},{"name":"code-review","description":"Review the current diff, or a PR number/branch/path target, for correctness bugs and reuse/simplification/efficiency cleanups at the given effort level (low/medium: fewer, high-confidence findings; high→max: broader coverage, may include uncertain findings; ultra: deep multi-agent review in the cloud); with no level given, it reuses the level you typed last. Pass --comment to post findings as inline PR comments, or --fix to apply the findings to the working tree after the review. For ultra on a GitHub.com PR target, --post asks to post the finished review’s findings to the PR as a single comment from the user’s GitHub account (not a review; the launch dialog still confirms in interactive sessions, while non-interactive mode posts on the flag alone) and --no-post hides that option.","input":{"hint":"[low|medium|high|xhigh|max|ultra] [--fix] [--comment] [||]"}},{"name":"simplify","description":"Review the changed code for reuse, simplification, efficiency, and altitude cleanups, then apply the fixes. Quality only — it does not hunt for bugs; use /code-review for that.","input":{"hint":"[]"}},{"name":"batch","description":"Research and plan a large-scale change, then execute it in parallel across 5–30 isolated worktree agents that each open a PR.","input":{"hint":""}},{"name":"fewer-permission-prompts","description":"Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.","input":null},{"name":"loop","description":"Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo). Omit the interval to let the model self-pace.","input":{"hint":"[interval] [prompt]"}},{"name":"schedule","description":"Create, update, list, or run scheduled cloud agents (routines) that execute on a cron schedule.","input":null},{"name":"claude-api","description":"Reference for the Claude API / Anthropic SDK — model ids, pricing, params, streaming, tool use, MCP, agents, caching, token counting, model migration.\nTRIGGER — read BEFORE opening the target file; don't skip because it \"looks like a one-liner\" — whenever: the prompt names Claude/Anthropic in any form (Claude, Anthropic, Fable, Opus, Sonnet, Haiku, `anthropic`, `@anthropic-ai`, `claude-*`, `us.anthropic.*`, `[1m]`); the user asks about an LLM (pricing/model choice/limits/caching) — never answer from memory; OR the task is LLM-shaped with provider unstated (agent/MCP/tool-definition/multi-agent/RAG/LLM-judge/computer-use; generate/summarize/extract/classify/rewrite/converse over NL; debugging refusals/cutoffs/streaming/tool-calls/tokens).\nSKIP only when another provider is being worked on (overrides all triggers): OpenAI/GPT/Gemini/Llama/Mistral/Cohere/Ollama named in the query; OR `grep -rE 'openai|langchain_openai|google.generativeai|genai|mistralai|cohere|ollama'` over the project hits (run this grep FIRST if no provider named — don't Read the file).","input":null},{"name":"workflow-authoring","description":"Reference for writing a Workflow tool script (script API and gotchas, resume, quality patterns, worked examples). Load before authoring a script for a workflow the user already opted into; it does not itself authorize running one.","input":null},{"name":"run","description":"Launch and drive this project's app to see a change working. Use when asked to run, start, or screenshot the app, or to confirm a change works in the real app (not just tests). First looks for a project skill that already covers launching the app; otherwise falls back to built-in patterns per project type (CLI, server, TUI, Electron, browser-driven, library).","input":null},{"name":"run-skill-generator","description":"Author or improve the run- skill - a per-project skill that tells agents how to build, launch, and drive this project's app. Use when the user asks to set up the project, get it running, write run instructions, or verify build/run steps work from a clean environment.","input":null},{"name":"agents","description":"(removed) Ask Claude to create/manage subagents, or edit .claude/agents/","input":null},{"name":"auto-mode-setup","description":"Teach auto mode about your environment, plus optional rule tweaks","input":{"hint":"[--request-id ] (--wizard posture=… scope=… depth=… --propose | --expect-sha256 <64-hex> --apply-file )"}},{"name":"autocompact","description":"Configure the auto-compact window size","input":{"hint":"[auto|]"}},{"name":"compact","description":"Free up context by summarizing the conversation so far","input":{"hint":""}},{"name":"config","description":"Set a setting by key","input":{"hint":"key=value"}},{"name":"context","description":"Show current context usage","input":null},{"name":"effort","description":"Set effort level for model usage","input":{"hint":""}},{"name":"fast","description":"Toggle fast mode (Opus 5)","input":{"hint":"[on|off]"}},{"name":"heapdump","description":"Dump the JS heap to ~/Desktop","input":null},{"name":"init","description":"Initialize a new CLAUDE.md file with codebase documentation","input":null},{"name":"mcp","description":"Manage MCP servers","input":{"hint":"[reconnect|enable|disable [|all]]"}},{"name":"import","description":"Import config from another AI coding agent","input":null},{"name":"model","description":"Set the AI model for Claude Code","input":{"hint":""}},{"name":"__remote-workflow","description":"Run the workflow script delivered in this session environment (server-launched sessions only)","input":null},{"name":"workflow-launch-exec","description":"Execute a server-launched workflow handoff (workflow_launch event sessions only)","input":null},{"name":"reload-skills","description":"Pick up skills added or changed on disk during this session","input":null},{"name":"rename","description":"Rename the current conversation","input":{"hint":"[name]"}},{"name":"ultrareview","description":"Start a cloud agent that finds and verifies bugs in your branch (~5-10 min, $5-$25 USD) · Runs in Claude Code on the web. See https://code.claude.com/docs/en/claude-code-on-the-web","input":null},{"name":"security-review","description":"Complete a security review of the pending changes on the current branch","input":null},{"name":"usage-credits","description":"Configure usage credits or request them from your admin when you hit a limit","input":null},{"name":"extra-usage","description":"Renamed to /usage-credits","input":null},{"name":"usage","description":"Show session cost, plan usage, and what's contributing to your limits","input":null},{"name":"insights","description":"Generate a report analyzing your Claude Code sessions","input":null},{"name":"recap","description":"Generate a one-line session recap now","input":null},{"name":"skill-doctor","description":"Show which loaded skills are unused and costing context","input":null},{"name":"goal","description":"Set a goal — keep working until the condition is met","input":null},{"name":"design","description":"Grant or revoke Claude agent access to your Design projects","input":{"hint":"consent | revoke"}},{"name":"design-consent","description":"Grant Claude agent access to your Design projects","input":null},{"name":"design-revoke","description":"Revoke Claude agent access to your Design projects","input":null},{"name":"list-agents","description":"List subagents, teammates, and other Claude sessions you can message","input":null},{"name":"team-onboarding","description":"Help teammates ramp on Claude Code with a guide from your usage","input":null}]}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","message":{"type":"system","subtype":"status","status":"requesting","session_id":"09165489-8be4-4ff7-a413-da1db464a44f","uuid":"234c3670-30aa-4c28-9578-22ed56fce13a"}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"_auth/status_update","params":{"authStatus":{"kind":"account","label":"Claude Max","account":{"plan":"max","email":"[redacted]","organization":"[redacted]"}}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","message":{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Return only the active invoice ID."}]},"session_id":"09165489-8be4-4ff7-a413-da1db464a44f","parent_tool_use_id":null,"uuid":"cdf79335-2c6a-418b-a45f-5cf0a050d8c8","timestamp":"2026-09-15T21:39:07.542Z","isReplay":true,"origin":{"kind":"human"}}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","message":{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-opus-5","id":"msg_011Cf5x1M2ZGPXyc7cnVVGoH","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":8918,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":8918},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null}},"session_id":"09165489-8be4-4ff7-a413-da1db464a44f","parent_tool_use_id":null,"uuid":"ef04061e-2556-4fec-8104-9ef493f88a03","ttft_ms":2620,"user_message_uuid":"cdf79335-2c6a-418b-a45f-5cf0a050d8c8"}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","update":{"sessionUpdate":"usage_update","used":8921,"size":1000000}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","message":{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}},"session_id":"09165489-8be4-4ff7-a413-da1db464a44f","parent_tool_use_id":null,"uuid":"3f9c7039-8caa-4f43-a8b9-554561ccabea"}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","message":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"IN"}},"session_id":"09165489-8be4-4ff7-a413-da1db464a44f","parent_tool_use_id":null,"uuid":"f8dd4f42-25c6-43ab-bd32-1582ab437440"}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"IN"},"messageId":"msg_011Cf5x1M2ZGPXyc7cnVVGoH"}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","message":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"V-1042"}},"session_id":"09165489-8be4-4ff7-a413-da1db464a44f","parent_tool_use_id":null,"uuid":"c4591b22-b70a-4fcf-9e87-febb3714a834"}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"V-1042"},"messageId":"msg_011Cf5x1M2ZGPXyc7cnVVGoH"}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","message":{"type":"assistant","message":{"model":"claude-opus-5","id":"msg_011Cf5x1M2ZGPXyc7cnVVGoH","type":"message","role":"assistant","content":[{"type":"text","text":"INV-1042"}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":8918,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":8918},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"09165489-8be4-4ff7-a413-da1db464a44f","uuid":"f0fbff0b-1221-4eac-88ae-ed1d3f81a41d","timestamp":"2026-09-15T21:39:10.196Z","request_id":"req_011Cf5x1LbkkVcyDWEaRkBu9"}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","message":{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"09165489-8be4-4ff7-a413-da1db464a44f","parent_tool_use_id":null,"uuid":"cab5a4ac-cd52-48d7-bda9-ba45d8e75079"}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","message":{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"container":null},"usage":{"input_tokens":2,"cache_creation_input_tokens":8918,"cache_read_input_tokens":0,"output_tokens":7,"output_tokens_details":{"thinking_tokens":0},"iterations":[{"input_tokens":2,"output_tokens":7,"cache_read_input_tokens":0,"cache_creation_input_tokens":8918,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":8918},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"09165489-8be4-4ff7-a413-da1db464a44f","parent_tool_use_id":null,"uuid":"403112d7-6174-4454-84c2-d210c5af40cc"}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","update":{"sessionUpdate":"usage_update","used":8927,"size":1000000}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","message":{"type":"stream_event","event":{"type":"message_stop"},"session_id":"09165489-8be4-4ff7-a413-da1db464a44f","parent_tool_use_id":null,"uuid":"70a503cd-874d-44ea-9a1a-4c549012d8a5"}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","message":{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1789513800,"rateLimitType":"five_hour","overageStatus":"rejected","overageDisabledReason":"org_level_disabled","isUsingOverage":false,"unifiedWindows":{"five_hour":{"utilization":0.35,"resetsAt":1789513800},"seven_day":{"utilization":0.18,"resetsAt":1789776000}}},"uuid":"caa42007-4e1c-4003-99b4-c86df1483864","session_id":"09165489-8be4-4ff7-a413-da1db464a44f"}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","update":{"sessionUpdate":"usage_update","used":8927,"size":1000000,"_meta":{"_claude/rateLimit":{"status":"allowed","resetsAt":1789513800,"rateLimitType":"five_hour","overageStatus":"rejected","overageDisabledReason":"org_level_disabled","isUsingOverage":false,"unifiedWindows":{"five_hour":{"utilization":0.35,"resetsAt":1789513800},"seven_day":{"utilization":0.18,"resetsAt":1789776000}}}}}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","message":{"duration_api_ms":2660,"stop_reason":"end_turn","session_id":"09165489-8be4-4ff7-a413-da1db464a44f","total_cost_usd":0.08936500000000001,"usage":{"input_tokens":2,"cache_creation_input_tokens":8918,"cache_read_input_tokens":0,"output_tokens":7,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":8918,"ephemeral_5m_input_tokens":0},"inference_geo":"not_available","iterations":[{"input_tokens":2,"output_tokens":7,"cache_read_input_tokens":0,"cache_creation_input_tokens":8918,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":8918},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-opus-5[1m]":{"inputTokens":2,"outputTokens":7,"cacheReadInputTokens":0,"cacheCreationInputTokens":8918,"webSearchRequests":0,"costUSD":0.08936500000000001,"contextWindow":1000000,"maxOutputTokens":64000,"thinkingTokens":0,"canonicalModel":"claude-opus-5","provider":"firstParty","costBasis":"list"}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","origin":{"kind":"human"},"subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"is_error":false,"num_turns":1,"subtype":"success","api_error_status":null,"result":"INV-1042","ttft_ms":2658,"type":"result","duration_ms":2682,"uuid":"24e3a01a-2528-4a84-b0b7-964d647d0659","ttft_stream_ms":2636,"time_to_request_ms":17,"user_message_uuid":"cdf79335-2c6a-418b-a45f-5cf0a050d8c8","request_sent_wall_ms":1789508347555,"queued_turn_count":0}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"09165489-8be4-4ff7-a413-da1db464a44f","update":{"sessionUpdate":"usage_update","used":8927,"size":1000000,"cost":{"amount":0.08936500000000001,"currency":"USD"},"_meta":{"_claude/origin":{"kind":"human"}}}}}} +{"connection":0,"from":"agent","message":{"jsonrpc":"2.0","id":5,"result":{"stopReason":"end_turn","usage":{"inputTokens":2,"outputTokens":7,"cachedReadTokens":0,"cachedWriteTokens":8918,"totalTokens":8927},"_meta":{"quota":{"token_count":{"totalTokens":8927,"inputTokens":2,"cachedInputTokens":0,"cachedWriteTokens":8918,"outputTokens":7,"reasoningOutputTokens":0},"model_usage":[{"model":"claude-opus-5[1m]","token_count":{"totalTokens":8927,"inputTokens":2,"cachedInputTokens":0,"cachedWriteTokens":8918,"outputTokens":7,"reasoningOutputTokens":0}}]}}}}} +{"connection":1,"from":"jp","message":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"_meta":{"claudeCode":{"promptQueueing":true},"authStatus":{}},"promptCapabilities":{"image":true,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":true},"auth":{"logout":{}},"providers":{},"loadSession":true,"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"0.76.0"},"authMethods":[],"_meta":{"jetbrains":{"air":{"version":1,"capabilities":["sessionFailure","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue"]}},"steering":{"supported":true},"goal":{"version":1,"controlMethod":"_session/goal","actions":["set","clear"]}}}}} +{"connection":1,"from":"jp","message":{"jsonrpc":"2.0","id":2,"method":"session/load","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","cwd":"[redacted]","mcpServers":[],"_meta":{"claudeCode":{"emitRawSDKMessages":true,"options":{"systemPrompt":{"type":"custom","prompt":"Qualification 2fc93839-f236-4058-b7f5-bf774840db8f. Use the supplied invoice history.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\n","snapshot":false},"model":"claude-opus-5","thinking":{"type":"disabled"},"tools":[],"allowedTools":[],"strictMcpConfig":true,"settingSources":[],"settings":{"disableAllHooks":true,"autoMemoryEnabled":false,"permissions":{"ask":["mcp__jp__*"]}},"persistSession":true,"env":{"CLAUDE_CODE_DISABLE_AUTO_MEMORY":"1","CLAUDE_CODE_DISABLE_BACKGROUND_TASKS":"1","CLAUDE_CODE_MAX_OUTPUT_TOKENS":"128","CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS":"0","DISABLE_AUTO_COMPACT":"1","ENABLE_TOOL_SEARCH":"false","MAX_MCP_OUTPUT_TOKENS":"100000"}}}}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"_auth/status_update","params":{"authStatus":{"kind":"account","label":"Claude Max","account":{"plan":"max","email":"[redacted]","organization":"[redacted]"}}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"_auth/status_update","params":{"authStatus":{"kind":"account","label":"Claude Max","account":{"plan":"Claude Max","email":"[redacted]","organization":"[redacted]"}}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"The active invoice is INV-1042."},"messageId":"9361e59d-07ec-5132-931b-74d8df40b763"}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Acknowledged."},"messageId":"msg_jp_00ad21e9161b537e993b077d48ef2f2e"}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","id":2,"result":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","modes":{"currentModeId":"default","availableModes":[{"id":"default","name":"Manual","description":"Always ask before making changes","_meta":{"kind":"standard"}},{"id":"acceptEdits","name":"Accept edits","description":"Automatically accept all file edits","_meta":{"kind":"standard"}},{"id":"plan","name":"Plan","description":"Create a plan before making changes","_meta":{"kind":"plan"}},{"id":"auto","name":"Auto","description":"Claude handles permission decisions","_meta":{"kind":"auto_review"}},{"id":"bypassPermissions","name":"Bypass permissions","description":"Accepts all permissions","_meta":{"kind":"full_access"}}]},"configOptions":[{"id":"mode","name":"Mode","description":"Session permission mode","category":"mode","type":"select","currentValue":"default","options":[{"value":"default","name":"Manual","description":"Always ask before making changes","_meta":{"kind":"standard"}},{"value":"acceptEdits","name":"Accept edits","description":"Automatically accept all file edits","_meta":{"kind":"standard"}},{"value":"plan","name":"Plan","description":"Create a plan before making changes","_meta":{"kind":"plan"}},{"value":"auto","name":"Auto","description":"Claude handles permission decisions","_meta":{"kind":"auto_review"}},{"value":"bypassPermissions","name":"Bypass permissions","description":"Accepts all permissions","_meta":{"kind":"full_access"}}]},{"id":"model","name":"Model","description":"AI model to use","category":"model","type":"select","currentValue":"default","options":[{"value":"default","name":"Default (recommended)","description":"Opus (1M context)"},{"value":"opus[1m]","name":"Opus 5","description":"Opus 5 with 1M context · Best for everyday, complex tasks"},{"value":"claude-fable-5-1[1m]","name":"Fable 5.1","description":"Fable 5.1 · Most capable for your hardest and longest-running tasks"},{"value":"sonnet","name":"Sonnet 5","description":"Sonnet 5 · Efficient for routine tasks"},{"value":"haiku","name":"Haiku 4.5","description":"Haiku 4.5 · Fastest for quick answers"}]},{"id":"effort","name":"Effort","description":"Available effort levels for this model","category":"thought_level","type":"select","currentValue":"default","options":[{"value":"default","name":"Default"},{"value":"low","name":"Low"},{"value":"medium","name":"Medium"},{"value":"high","name":"High"},{"value":"xhigh","name":"Xhigh"},{"value":"max","name":"Max"}]},{"id":"fast","name":"Fast mode","description":"Faster responses on supported models","category":"model_config","type":"select","currentValue":"off","options":[{"value":"on","name":"On"},{"value":"off","name":"Off"}]}]}}} +{"connection":1,"from":"jp","message":{"jsonrpc":"2.0","id":3,"method":"session/set_config_option","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","configId":"model","value":"claude-opus-5"}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"deep-research","description":"Deep research harness — fan-out web searches, fetch sources, adversarially verify claims, synthesize a cited report. (dynamic workflow)","input":null},{"name":"design-sync","description":"Push a React design system to claude.ai/design. This runs a converter that bundles the real component code (from Storybook or a bare package) and uploads it. Use when the user runs /design-sync or says \"sync my design system to Claude Design\".","input":{"hint":"[]"}},{"name":"dataviz","description":"Use this skill whenever you are about to create ANY chart, graph, plot, dashboard, or data visualization, in ANY output medium — an HTML or React artifact, inline SVG, plotting code in any library (matplotlib, plotly, d3, Recharts, …), an image/PNG you will render and upload, or a chart shared into Slack. Read it BEFORE writing the first line of chart code, choosing chart colors, building a stat tile / meter / KPI row, or laying out a dashboard. When the destination is a first-party document connector (host-designated, never self-described) that renders live charts, hand it the rows (inline, or as an uploaded data file the chart cites) rather than a rendered PNG/SVG — a picture of a chart loses hover, data inspection and per-value comments. Produces visualizations that read as one system — elegant, accessible, consistent in light and dark — using a brand-neutral placeholder palette you swap for your own. Teaches a design-system-agnostic method: a form heuristic, a color formula with a runnable validator, mark specs, and interaction rules. A validated default palette is documented in `references/palette.md` — swap that file's values for your brand's. Triggers on: \"chart\", \"graph\", \"plot\", \"data viz\", \"visualization\", \"dashboard\", \"analytics\", \"visualize data\", \"categorical colors\", \"sequential / diverging palette\", \"stat tile\", \"sparkline\", \"heatmap\", \"legend\", \"axis\", \"tooltip\", \"chart colors\", \"color by series\".","input":null},{"name":"update-config","description":"Use this skill to configure the Claude Code harness via settings.json. Automated behaviors (\"from now on when X\", \"each time X\", \"whenever X\", \"before/after X\") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions (\"allow X\", \"add permission\", \"move permission to\"), env vars (\"set X=Y\"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: \"allow npm commands\", \"add bq permission to global settings\", \"move permission to user settings\", \"set DEBUG=true\", \"when claude stops show X\". For simple settings like theme/model, suggest the /config command.","input":null},{"name":"verify","description":"Verify that a code change actually does what it's supposed to by exercising it end-to-end and observing behavior — drive the affected flow, not just tests or typecheck. Run before committing nontrivial changes; bootstraps this repo's project verify skill if none exists yet. Don't invoke it on a diff that only touches tests, docs, or other code with no runtime surface to drive (a change to product source always has one) — there's nothing to observe.","input":null},{"name":"debug","description":"Enable debug logging for this session and help diagnose issues","input":{"hint":"[issue description]"}},{"name":"code-review","description":"Review the current diff, or a PR number/branch/path target, for correctness bugs and reuse/simplification/efficiency cleanups at the given effort level (low/medium: fewer, high-confidence findings; high→max: broader coverage, may include uncertain findings; ultra: deep multi-agent review in the cloud); with no level given, it reuses the level you typed last. Pass --comment to post findings as inline PR comments, or --fix to apply the findings to the working tree after the review. For ultra on a GitHub.com PR target, --post asks to post the finished review’s findings to the PR as a single comment from the user’s GitHub account (not a review; the launch dialog still confirms in interactive sessions, while non-interactive mode posts on the flag alone) and --no-post hides that option.","input":{"hint":"[low|medium|high|xhigh|max|ultra] [--fix] [--comment] [||]"}},{"name":"simplify","description":"Review the changed code for reuse, simplification, efficiency, and altitude cleanups, then apply the fixes. Quality only — it does not hunt for bugs; use /code-review for that.","input":{"hint":"[]"}},{"name":"batch","description":"Research and plan a large-scale change, then execute it in parallel across 5–30 isolated worktree agents that each open a PR.","input":{"hint":""}},{"name":"fewer-permission-prompts","description":"Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.","input":null},{"name":"doctor","description":"Health-check the user's Claude Code setup and fix issues: diagnose installation health — what the `claude doctor` terminal diagnostics cover — from local data (duplicate or leftover installs, PATH, unparseable settings files, broken or colliding agent definitions, skills whose frontmatter fails to parse); find unused skills, MCP servers, and plugins versus their context cost and disable dead weight; deduplicate local CLAUDE.md files against checked-in ones; trim checked-in CLAUDE.md files by cutting content a session could derive from the codebase (directory layouts, tech-stack lists, architecture overviews) while keeping gotchas, rationale, and non-standard conventions; migrate always-loaded CLAUDE.md guidance into lazy skills and nested CLAUDE.md files; flag slow hooks and context-heavy extensions; check the installed version is current; make auto mode the default permission mode; and pre-approve frequently denied read-only commands. Use when the user asks for a doctor run, checkup, audit, tune-up, or cleanup of their Claude Code setup or configuration.","input":null},{"name":"loop","description":"Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo). Omit the interval to let the model self-pace.","input":{"hint":"[interval] [prompt]"}},{"name":"schedule","description":"Create, update, list, or run scheduled cloud agents (routines) that execute on a cron schedule.","input":null},{"name":"claude-api","description":"Reference for the Claude API / Anthropic SDK — model ids, pricing, params, streaming, tool use, MCP, agents, caching, token counting, model migration.\nTRIGGER — read BEFORE opening the target file; don't skip because it \"looks like a one-liner\" — whenever: the prompt names Claude/Anthropic in any form (Claude, Anthropic, Fable, Opus, Sonnet, Haiku, `anthropic`, `@anthropic-ai`, `claude-*`, `us.anthropic.*`, `[1m]`); the user asks about an LLM (pricing/model choice/limits/caching) — never answer from memory; OR the task is LLM-shaped with provider unstated (agent/MCP/tool-definition/multi-agent/RAG/LLM-judge/computer-use; generate/summarize/extract/classify/rewrite/converse over NL; debugging refusals/cutoffs/streaming/tool-calls/tokens).\nSKIP only when another provider is being worked on (overrides all triggers): OpenAI/GPT/Gemini/Llama/Mistral/Cohere/Ollama named in the query; OR `grep -rE 'openai|langchain_openai|google.generativeai|genai|mistralai|cohere|ollama'` over the project hits (run this grep FIRST if no provider named — don't Read the file).","input":null},{"name":"workflow-authoring","description":"Reference for writing a Workflow tool script (script API and gotchas, resume, quality patterns, worked examples). Load before authoring a script for a workflow the user already opted into; it does not itself authorize running one.","input":null},{"name":"run","description":"Launch and drive this project's app to see a change working. Use when asked to run, start, or screenshot the app, or to confirm a change works in the real app (not just tests). First looks for a project skill that already covers launching the app; otherwise falls back to built-in patterns per project type (CLI, server, TUI, Electron, browser-driven, library).","input":null},{"name":"run-skill-generator","description":"Author or improve the run- skill - a per-project skill that tells agents how to build, launch, and drive this project's app. Use when the user asks to set up the project, get it running, write run instructions, or verify build/run steps work from a clean environment.","input":null},{"name":"agents","description":"(removed) Ask Claude to create/manage subagents, or edit .claude/agents/","input":null},{"name":"auto-mode-setup","description":"Teach auto mode about your environment, plus optional rule tweaks","input":{"hint":"[--request-id ] (--wizard posture=… scope=… depth=… --propose | --expect-sha256 <64-hex> --apply-file )"}},{"name":"autocompact","description":"Configure the auto-compact window size","input":{"hint":"[auto|]"}},{"name":"color","description":"Set the prompt bar color for this session","input":{"hint":"[red|blue|green|yellow|purple|orange|pink|cyan|default]"}},{"name":"compact","description":"Free up context by summarizing the conversation so far","input":{"hint":""}},{"name":"config","description":"Set a setting by key","input":{"hint":"key=value"}},{"name":"context","description":"Show current context usage","input":null},{"name":"effort","description":"Set effort level for model usage","input":{"hint":""}},{"name":"fast","description":"Toggle fast mode (Opus 5)","input":{"hint":"[on|off]"}},{"name":"heapdump","description":"Dump the JS heap to ~/Desktop","input":null},{"name":"init","description":"Initialize a new CLAUDE.md file with codebase documentation","input":null},{"name":"mcp","description":"Manage MCP servers","input":{"hint":"[reconnect|enable|disable [|all]]"}},{"name":"import","description":"Import config from another AI coding agent","input":null},{"name":"model","description":"Set the AI model for Claude Code","input":{"hint":""}},{"name":"__remote-workflow","description":"Run the workflow script delivered in this session environment (server-launched sessions only)","input":null},{"name":"workflow-launch-exec","description":"Execute a server-launched workflow handoff (workflow_launch event sessions only)","input":null},{"name":"reload-skills","description":"Pick up skills added or changed on disk during this session","input":null},{"name":"rename","description":"Rename the current conversation","input":{"hint":"[name]"}},{"name":"ultrareview","description":"Start a cloud agent that finds and verifies bugs in your branch (~5-10 min, $5-$25 USD) · Runs in Claude Code on the web. See https://code.claude.com/docs/en/claude-code-on-the-web","input":null},{"name":"security-review","description":"Complete a security review of the pending changes on the current branch","input":null},{"name":"usage-credits","description":"Configure usage credits or request them from your admin when you hit a limit","input":null},{"name":"extra-usage","description":"Renamed to /usage-credits","input":null},{"name":"usage","description":"Show session cost, plan usage, and what's contributing to your limits","input":null},{"name":"insights","description":"Generate a report analyzing your Claude Code sessions","input":null},{"name":"recap","description":"Generate a one-line session recap now","input":null},{"name":"skill-doctor","description":"Show which loaded skills are unused and costing context","input":null},{"name":"goal","description":"Set a goal — keep working until the condition is met","input":null},{"name":"design","description":"Grant or revoke Claude agent access to your Design projects","input":{"hint":"consent | revoke"}},{"name":"design-consent","description":"Grant Claude agent access to your Design projects","input":null},{"name":"design-revoke","description":"Revoke Claude agent access to your Design projects","input":null},{"name":"list-agents","description":"List subagents, teammates, and other Claude sessions you can message","input":null},{"name":"team-onboarding","description":"Help teammates ramp on Claude Code with a guide from your usage","input":null}]}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"mode","name":"Mode","description":"Session permission mode","category":"mode","type":"select","currentValue":"default","options":[{"value":"default","name":"Manual","description":"Always ask before making changes","_meta":{"kind":"standard"}},{"value":"acceptEdits","name":"Accept edits","description":"Automatically accept all file edits","_meta":{"kind":"standard"}},{"value":"plan","name":"Plan","description":"Create a plan before making changes","_meta":{"kind":"plan"}},{"value":"auto","name":"Auto","description":"Claude handles permission decisions","_meta":{"kind":"auto_review"}},{"value":"bypassPermissions","name":"Bypass permissions","description":"Accepts all permissions","_meta":{"kind":"full_access"}}]},{"id":"model","name":"Model","description":"AI model to use","category":"model","type":"select","currentValue":"opus[1m]","options":[{"value":"default","name":"Default (recommended)","description":"Opus (1M context)"},{"value":"opus[1m]","name":"Opus 5","description":"Opus 5 with 1M context · Best for everyday, complex tasks"},{"value":"claude-fable-5-1[1m]","name":"Fable 5.1","description":"Fable 5.1 · Most capable for your hardest and longest-running tasks"},{"value":"sonnet","name":"Sonnet 5","description":"Sonnet 5 · Efficient for routine tasks"},{"value":"haiku","name":"Haiku 4.5","description":"Haiku 4.5 · Fastest for quick answers"}]},{"id":"effort","name":"Effort","description":"Available effort levels for this model","category":"thought_level","type":"select","currentValue":"default","options":[{"value":"default","name":"Default"},{"value":"low","name":"Low"},{"value":"medium","name":"Medium"},{"value":"high","name":"High"},{"value":"xhigh","name":"Xhigh"},{"value":"max","name":"Max"}]},{"id":"fast","name":"Fast mode","description":"Faster responses on supported models","category":"model_config","type":"select","currentValue":"off","options":[{"value":"on","name":"On"},{"value":"off","name":"Off"}]}]}}} +{"connection":1,"from":"jp","message":{"jsonrpc":"2.0","id":4,"method":"session/set_config_option","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","configId":"mode","value":"default"}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","update":{"sessionUpdate":"current_mode_update","currentModeId":"default"}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"mode","name":"Mode","description":"Session permission mode","category":"mode","type":"select","currentValue":"default","options":[{"value":"default","name":"Manual","description":"Always ask before making changes","_meta":{"kind":"standard"}},{"value":"acceptEdits","name":"Accept edits","description":"Automatically accept all file edits","_meta":{"kind":"standard"}},{"value":"plan","name":"Plan","description":"Create a plan before making changes","_meta":{"kind":"plan"}},{"value":"auto","name":"Auto","description":"Claude handles permission decisions","_meta":{"kind":"auto_review"}},{"value":"bypassPermissions","name":"Bypass permissions","description":"Accepts all permissions","_meta":{"kind":"full_access"}}]},{"id":"model","name":"Model","description":"AI model to use","category":"model","type":"select","currentValue":"opus[1m]","options":[{"value":"default","name":"Default (recommended)","description":"Opus (1M context)"},{"value":"opus[1m]","name":"Opus 5","description":"Opus 5 with 1M context · Best for everyday, complex tasks"},{"value":"claude-fable-5-1[1m]","name":"Fable 5.1","description":"Fable 5.1 · Most capable for your hardest and longest-running tasks"},{"value":"sonnet","name":"Sonnet 5","description":"Sonnet 5 · Efficient for routine tasks"},{"value":"haiku","name":"Haiku 4.5","description":"Haiku 4.5 · Fastest for quick answers"}]},{"id":"effort","name":"Effort","description":"Available effort levels for this model","category":"thought_level","type":"select","currentValue":"default","options":[{"value":"default","name":"Default"},{"value":"low","name":"Low"},{"value":"medium","name":"Medium"},{"value":"high","name":"High"},{"value":"xhigh","name":"Xhigh"},{"value":"max","name":"Max"}]},{"id":"fast","name":"Fast mode","description":"Faster responses on supported models","category":"model_config","type":"select","currentValue":"off","options":[{"value":"on","name":"On"},{"value":"off","name":"Off"}]}]}}} +{"connection":1,"from":"jp","message":{"jsonrpc":"2.0","id":5,"method":"session/prompt","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","prompt":[{"type":"text","text":"Return only the active invoice ID."}]}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","message":{"type":"user","message":{"role":"user","content":"Set model to `opus[1m] (claude-opus-5[1m])`"},"session_id":"8fb35022-5554-4994-ac42-934a6213cf0b","parent_tool_use_id":null,"uuid":"5ad7d8f0-4ed1-47d1-9c2e-033f7342d4d0","timestamp":"2026-09-15T21:39:11.049Z","isReplay":true}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","message":{"type":"system","subtype":"session_state_changed","state":"running","uuid":"7e768a21-c1f3-4649-8539-db334d25f5da","session_id":"8fb35022-5554-4994-ac42-934a6213cf0b"}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","message":{"type":"command_lifecycle","command_uuid":"92d74b3c-42d5-4cac-8e15-7d802f30971b","state":"queued","uuid":"64296087-caf1-4eac-ac3a-c007468458a6","session_id":"8fb35022-5554-4994-ac42-934a6213cf0b"}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","message":{"type":"command_lifecycle","command_uuid":"92d74b3c-42d5-4cac-8e15-7d802f30971b","state":"started","uuid":"553d1d2d-037f-443d-b715-5fd05b2573ad","session_id":"8fb35022-5554-4994-ac42-934a6213cf0b"}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","message":{"type":"system","subtype":"init","cwd":"[redacted]","session_id":"8fb35022-5554-4994-ac42-934a6213cf0b","tools":[],"mcp_servers":[],"model":"claude-opus-5[1m]","permissionMode":"default","slash_commands":["deep-research","design-sync","dataviz","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","doctor","loop","schedule","claude-api","workflow-authoring","run","run-skill-generator","agents","auto-mode-setup","autocompact","clear","color","compact","config","context","effort","fast","heapdump","init","mcp","import","model","__remote-workflow","workflow-launch-exec","reload-skills","rename","ultrareview","security-review","usage-credits","extra-usage","usage","insights","recap","skill-doctor","goal","design","design-consent","design-revoke","list-agents","team-onboarding"],"terminal_slash_commands":["doctor","color"],"apiKeySource":"none","claude_code_version":"2.1.257","output_style":"default","agents":["claude","Explore","general-purpose","Plan","statusline-setup"],"skills":["deep-research","design-sync","dataviz","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","doctor","loop","schedule","claude-api","workflow-authoring","run","run-skill-generator"],"plugins":[],"capabilities":["interrupt_receipt_v1","interrupt_cancel_queued_v1","msg_lifecycle_v1"],"analytics_disabled":false,"product_feedback_disabled":false,"uuid":"03ccaa30-fd5a-4706-ab5a-acc53d14022f","messaging_socket_path":"/tmp/cc-socks/7712.sock","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required"}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"deep-research","description":"Deep research harness — fan-out web searches, fetch sources, adversarially verify claims, synthesize a cited report. (dynamic workflow)","input":null},{"name":"design-sync","description":"Push a React design system to claude.ai/design. This runs a converter that bundles the real component code (from Storybook or a bare package) and uploads it. Use when the user runs /design-sync or says \"sync my design system to Claude Design\".","input":{"hint":"[]"}},{"name":"dataviz","description":"Use this skill whenever you are about to create ANY chart, graph, plot, dashboard, or data visualization, in ANY output medium — an HTML or React artifact, inline SVG, plotting code in any library (matplotlib, plotly, d3, Recharts, …), an image/PNG you will render and upload, or a chart shared into Slack. Read it BEFORE writing the first line of chart code, choosing chart colors, building a stat tile / meter / KPI row, or laying out a dashboard. When the destination is a first-party document connector (host-designated, never self-described) that renders live charts, hand it the rows (inline, or as an uploaded data file the chart cites) rather than a rendered PNG/SVG — a picture of a chart loses hover, data inspection and per-value comments. Produces visualizations that read as one system — elegant, accessible, consistent in light and dark — using a brand-neutral placeholder palette you swap for your own. Teaches a design-system-agnostic method: a form heuristic, a color formula with a runnable validator, mark specs, and interaction rules. A validated default palette is documented in `references/palette.md` — swap that file's values for your brand's. Triggers on: \"chart\", \"graph\", \"plot\", \"data viz\", \"visualization\", \"dashboard\", \"analytics\", \"visualize data\", \"categorical colors\", \"sequential / diverging palette\", \"stat tile\", \"sparkline\", \"heatmap\", \"legend\", \"axis\", \"tooltip\", \"chart colors\", \"color by series\".","input":null},{"name":"update-config","description":"Use this skill to configure the Claude Code harness via settings.json. Automated behaviors (\"from now on when X\", \"each time X\", \"whenever X\", \"before/after X\") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions (\"allow X\", \"add permission\", \"move permission to\"), env vars (\"set X=Y\"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: \"allow npm commands\", \"add bq permission to global settings\", \"move permission to user settings\", \"set DEBUG=true\", \"when claude stops show X\". For simple settings like theme/model, suggest the /config command.","input":null},{"name":"verify","description":"Verify that a code change actually does what it's supposed to by exercising it end-to-end and observing behavior — drive the affected flow, not just tests or typecheck. Run before committing nontrivial changes; bootstraps this repo's project verify skill if none exists yet. Don't invoke it on a diff that only touches tests, docs, or other code with no runtime surface to drive (a change to product source always has one) — there's nothing to observe.","input":null},{"name":"debug","description":"Enable debug logging for this session and help diagnose issues","input":{"hint":"[issue description]"}},{"name":"code-review","description":"Review the current diff, or a PR number/branch/path target, for correctness bugs and reuse/simplification/efficiency cleanups at the given effort level (low/medium: fewer, high-confidence findings; high→max: broader coverage, may include uncertain findings; ultra: deep multi-agent review in the cloud); with no level given, it reuses the level you typed last. Pass --comment to post findings as inline PR comments, or --fix to apply the findings to the working tree after the review. For ultra on a GitHub.com PR target, --post asks to post the finished review’s findings to the PR as a single comment from the user’s GitHub account (not a review; the launch dialog still confirms in interactive sessions, while non-interactive mode posts on the flag alone) and --no-post hides that option.","input":{"hint":"[low|medium|high|xhigh|max|ultra] [--fix] [--comment] [||]"}},{"name":"simplify","description":"Review the changed code for reuse, simplification, efficiency, and altitude cleanups, then apply the fixes. Quality only — it does not hunt for bugs; use /code-review for that.","input":{"hint":"[]"}},{"name":"batch","description":"Research and plan a large-scale change, then execute it in parallel across 5–30 isolated worktree agents that each open a PR.","input":{"hint":""}},{"name":"fewer-permission-prompts","description":"Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.","input":null},{"name":"loop","description":"Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo). Omit the interval to let the model self-pace.","input":{"hint":"[interval] [prompt]"}},{"name":"schedule","description":"Create, update, list, or run scheduled cloud agents (routines) that execute on a cron schedule.","input":null},{"name":"claude-api","description":"Reference for the Claude API / Anthropic SDK — model ids, pricing, params, streaming, tool use, MCP, agents, caching, token counting, model migration.\nTRIGGER — read BEFORE opening the target file; don't skip because it \"looks like a one-liner\" — whenever: the prompt names Claude/Anthropic in any form (Claude, Anthropic, Fable, Opus, Sonnet, Haiku, `anthropic`, `@anthropic-ai`, `claude-*`, `us.anthropic.*`, `[1m]`); the user asks about an LLM (pricing/model choice/limits/caching) — never answer from memory; OR the task is LLM-shaped with provider unstated (agent/MCP/tool-definition/multi-agent/RAG/LLM-judge/computer-use; generate/summarize/extract/classify/rewrite/converse over NL; debugging refusals/cutoffs/streaming/tool-calls/tokens).\nSKIP only when another provider is being worked on (overrides all triggers): OpenAI/GPT/Gemini/Llama/Mistral/Cohere/Ollama named in the query; OR `grep -rE 'openai|langchain_openai|google.generativeai|genai|mistralai|cohere|ollama'` over the project hits (run this grep FIRST if no provider named — don't Read the file).","input":null},{"name":"workflow-authoring","description":"Reference for writing a Workflow tool script (script API and gotchas, resume, quality patterns, worked examples). Load before authoring a script for a workflow the user already opted into; it does not itself authorize running one.","input":null},{"name":"run","description":"Launch and drive this project's app to see a change working. Use when asked to run, start, or screenshot the app, or to confirm a change works in the real app (not just tests). First looks for a project skill that already covers launching the app; otherwise falls back to built-in patterns per project type (CLI, server, TUI, Electron, browser-driven, library).","input":null},{"name":"run-skill-generator","description":"Author or improve the run- skill - a per-project skill that tells agents how to build, launch, and drive this project's app. Use when the user asks to set up the project, get it running, write run instructions, or verify build/run steps work from a clean environment.","input":null},{"name":"agents","description":"(removed) Ask Claude to create/manage subagents, or edit .claude/agents/","input":null},{"name":"auto-mode-setup","description":"Teach auto mode about your environment, plus optional rule tweaks","input":{"hint":"[--request-id ] (--wizard posture=… scope=… depth=… --propose | --expect-sha256 <64-hex> --apply-file )"}},{"name":"autocompact","description":"Configure the auto-compact window size","input":{"hint":"[auto|]"}},{"name":"compact","description":"Free up context by summarizing the conversation so far","input":{"hint":""}},{"name":"config","description":"Set a setting by key","input":{"hint":"key=value"}},{"name":"context","description":"Show current context usage","input":null},{"name":"effort","description":"Set effort level for model usage","input":{"hint":""}},{"name":"fast","description":"Toggle fast mode (Opus 5)","input":{"hint":"[on|off]"}},{"name":"heapdump","description":"Dump the JS heap to ~/Desktop","input":null},{"name":"init","description":"Initialize a new CLAUDE.md file with codebase documentation","input":null},{"name":"mcp","description":"Manage MCP servers","input":{"hint":"[reconnect|enable|disable [|all]]"}},{"name":"import","description":"Import config from another AI coding agent","input":null},{"name":"model","description":"Set the AI model for Claude Code","input":{"hint":""}},{"name":"__remote-workflow","description":"Run the workflow script delivered in this session environment (server-launched sessions only)","input":null},{"name":"workflow-launch-exec","description":"Execute a server-launched workflow handoff (workflow_launch event sessions only)","input":null},{"name":"reload-skills","description":"Pick up skills added or changed on disk during this session","input":null},{"name":"rename","description":"Rename the current conversation","input":{"hint":"[name]"}},{"name":"ultrareview","description":"Start a cloud agent that finds and verifies bugs in your branch (~5-10 min, $5-$25 USD) · Runs in Claude Code on the web. See https://code.claude.com/docs/en/claude-code-on-the-web","input":null},{"name":"security-review","description":"Complete a security review of the pending changes on the current branch","input":null},{"name":"usage-credits","description":"Configure usage credits or request them from your admin when you hit a limit","input":null},{"name":"extra-usage","description":"Renamed to /usage-credits","input":null},{"name":"usage","description":"Show session cost, plan usage, and what's contributing to your limits","input":null},{"name":"insights","description":"Generate a report analyzing your Claude Code sessions","input":null},{"name":"recap","description":"Generate a one-line session recap now","input":null},{"name":"skill-doctor","description":"Show which loaded skills are unused and costing context","input":null},{"name":"goal","description":"Set a goal — keep working until the condition is met","input":null},{"name":"design","description":"Grant or revoke Claude agent access to your Design projects","input":{"hint":"consent | revoke"}},{"name":"design-consent","description":"Grant Claude agent access to your Design projects","input":null},{"name":"design-revoke","description":"Revoke Claude agent access to your Design projects","input":null},{"name":"list-agents","description":"List subagents, teammates, and other Claude sessions you can message","input":null},{"name":"team-onboarding","description":"Help teammates ramp on Claude Code with a guide from your usage","input":null}]}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","message":{"type":"system","subtype":"status","status":"requesting","session_id":"8fb35022-5554-4994-ac42-934a6213cf0b","uuid":"b81d0bd9-471a-4b48-97e3-6060c921dc4d"}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"_auth/status_update","params":{"authStatus":{"kind":"account","label":"Claude Max","account":{"plan":"max","email":"[redacted]","organization":"[redacted]"}}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","message":{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Return only the active invoice ID."}]},"session_id":"8fb35022-5554-4994-ac42-934a6213cf0b","parent_tool_use_id":null,"uuid":"92d74b3c-42d5-4cac-8e15-7d802f30971b","timestamp":"2026-09-15T21:39:11.062Z","isReplay":true,"origin":{"kind":"human"}}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","message":{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-opus-5","id":"msg_011Cf5x1c2htZ4Mi7o5MCbcu","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":0,"cache_read_input_tokens":8918,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null}},"session_id":"8fb35022-5554-4994-ac42-934a6213cf0b","parent_tool_use_id":null,"uuid":"efb816c7-bc73-4fb7-8eb8-bb7a55837c4c","ttft_ms":798,"user_message_uuid":"92d74b3c-42d5-4cac-8e15-7d802f30971b"}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","update":{"sessionUpdate":"usage_update","used":8921,"size":1000000}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","message":{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}},"session_id":"8fb35022-5554-4994-ac42-934a6213cf0b","parent_tool_use_id":null,"uuid":"64cc6e04-3c0f-45cc-a279-32223904b066"}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","message":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"IN"}},"session_id":"8fb35022-5554-4994-ac42-934a6213cf0b","parent_tool_use_id":null,"uuid":"fa6941f4-45c9-4d3e-acbd-5bc0def4a03b"}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"IN"},"messageId":"msg_011Cf5x1c2htZ4Mi7o5MCbcu"}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","message":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"V-1042"}},"session_id":"8fb35022-5554-4994-ac42-934a6213cf0b","parent_tool_use_id":null,"uuid":"d694efd4-9d9f-4bb5-93cf-30f4b5e64a08"}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"V-1042"},"messageId":"msg_011Cf5x1c2htZ4Mi7o5MCbcu"}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","message":{"type":"assistant","message":{"model":"claude-opus-5","id":"msg_011Cf5x1c2htZ4Mi7o5MCbcu","type":"message","role":"assistant","content":[{"type":"text","text":"INV-1042"}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":0,"cache_read_input_tokens":8918,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"8fb35022-5554-4994-ac42-934a6213cf0b","uuid":"47baf8f5-c29b-45b9-bd99-b8f5bd6a8543","timestamp":"2026-09-15T21:39:12.499Z","request_id":"req_011Cf5x1bYvVXeyHXpSG7c76"}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","message":{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"8fb35022-5554-4994-ac42-934a6213cf0b","parent_tool_use_id":null,"uuid":"8e4d50ce-125c-4591-9811-4c531e8d333d"}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","message":{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"container":null},"usage":{"input_tokens":2,"cache_creation_input_tokens":0,"cache_read_input_tokens":8918,"output_tokens":7,"output_tokens_details":{"thinking_tokens":0},"iterations":[{"input_tokens":2,"output_tokens":7,"cache_read_input_tokens":8918,"cache_creation_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"8fb35022-5554-4994-ac42-934a6213cf0b","parent_tool_use_id":null,"uuid":"aeccf835-0b9a-4a4a-92b6-3ca4a7cf77b6"}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","update":{"sessionUpdate":"usage_update","used":8927,"size":1000000}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","message":{"type":"stream_event","event":{"type":"message_stop"},"session_id":"8fb35022-5554-4994-ac42-934a6213cf0b","parent_tool_use_id":null,"uuid":"2074c22c-efa9-4284-a776-46e2896baa04"}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","message":{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1789513800,"rateLimitType":"five_hour","overageStatus":"rejected","overageDisabledReason":"org_level_disabled","isUsingOverage":false,"unifiedWindows":{"five_hour":{"utilization":0.35,"resetsAt":1789513800},"seven_day":{"utilization":0.18,"resetsAt":1789776000}}},"uuid":"9eadecf8-baa4-4ec7-9016-a76b584863d1","session_id":"8fb35022-5554-4994-ac42-934a6213cf0b"}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","update":{"sessionUpdate":"usage_update","used":8927,"size":1000000,"_meta":{"_claude/rateLimit":{"status":"allowed","resetsAt":1789513800,"rateLimitType":"five_hour","overageStatus":"rejected","overageDisabledReason":"org_level_disabled","isUsingOverage":false,"unifiedWindows":{"five_hour":{"utilization":0.35,"resetsAt":1789513800},"seven_day":{"utilization":0.18,"resetsAt":1789776000}}}}}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","message":{"duration_api_ms":1462,"stop_reason":"end_turn","session_id":"8fb35022-5554-4994-ac42-934a6213cf0b","total_cost_usd":0.0046440000000000006,"usage":{"input_tokens":2,"cache_creation_input_tokens":0,"cache_read_input_tokens":8918,"output_tokens":7,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"not_available","iterations":[{"input_tokens":2,"output_tokens":7,"cache_read_input_tokens":8918,"cache_creation_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-opus-5[1m]":{"inputTokens":2,"outputTokens":7,"cacheReadInputTokens":8918,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.0046440000000000006,"contextWindow":1000000,"maxOutputTokens":64000,"thinkingTokens":0,"canonicalModel":"claude-opus-5","provider":"firstParty","costBasis":"list"}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","origin":{"kind":"human"},"subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"is_error":false,"num_turns":1,"subtype":"success","api_error_status":null,"result":"INV-1042","ttft_ms":1443,"type":"result","duration_ms":1479,"uuid":"3f8a96fe-ab60-448c-9967-40c48f8d11fb","ttft_stream_ms":814,"time_to_request_ms":16,"user_message_uuid":"92d74b3c-42d5-4cac-8e15-7d802f30971b","request_sent_wall_ms":1789508351075,"queued_turn_count":0}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"8fb35022-5554-4994-ac42-934a6213cf0b","update":{"sessionUpdate":"usage_update","used":8927,"size":1000000,"cost":{"amount":0.0046440000000000006,"currency":"USD"},"_meta":{"_claude/origin":{"kind":"human"}}}}}} +{"connection":1,"from":"agent","message":{"jsonrpc":"2.0","id":5,"result":{"stopReason":"end_turn","usage":{"inputTokens":2,"outputTokens":7,"cachedReadTokens":8918,"cachedWriteTokens":0,"totalTokens":8927},"_meta":{"quota":{"token_count":{"totalTokens":8927,"inputTokens":2,"cachedInputTokens":8918,"cachedWriteTokens":0,"outputTokens":7,"reasoningOutputTokens":0},"model_usage":[{"model":"claude-opus-5[1m]","token_count":{"totalTokens":8927,"inputTokens":2,"cachedInputTokens":8918,"cachedWriteTokens":0,"outputTokens":7,"reasoningOutputTokens":0}}]}}}}} +{"connection":2,"from":"jp","message":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"_meta":{"claudeCode":{"promptQueueing":true},"authStatus":{}},"promptCapabilities":{"image":true,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":true},"auth":{"logout":{}},"providers":{},"loadSession":true,"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"0.76.0"},"authMethods":[],"_meta":{"jetbrains":{"air":{"version":1,"capabilities":["sessionFailure","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue"]}},"steering":{"supported":true},"goal":{"version":1,"controlMethod":"_session/goal","actions":["set","clear"]}}}}} +{"connection":2,"from":"jp","message":{"jsonrpc":"2.0","id":2,"method":"session/load","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","cwd":"[redacted]","mcpServers":[],"_meta":{"claudeCode":{"emitRawSDKMessages":true,"options":{"systemPrompt":{"type":"custom","prompt":"Qualification 2fc93839-f236-4058-b7f5-bf774840db8f. Use the supplied invoice history.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\nInvoice INV-1042: amount 125, status paid.\n","snapshot":false},"model":"claude-opus-5","thinking":{"type":"disabled"},"tools":[],"allowedTools":[],"strictMcpConfig":true,"settingSources":[],"settings":{"disableAllHooks":true,"autoMemoryEnabled":false,"permissions":{"ask":["mcp__jp__*"]}},"persistSession":true,"env":{"CLAUDE_CODE_DISABLE_AUTO_MEMORY":"1","CLAUDE_CODE_DISABLE_BACKGROUND_TASKS":"1","CLAUDE_CODE_MAX_OUTPUT_TOKENS":"128","CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS":"0","DISABLE_AUTO_COMPACT":"1","DISABLE_PROMPT_CACHING":"1","ENABLE_TOOL_SEARCH":"false","MAX_MCP_OUTPUT_TOKENS":"100000"}}}}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"_auth/status_update","params":{"authStatus":{"kind":"account","label":"Claude Max","account":{"plan":"max","email":"[redacted]","organization":"[redacted]"}}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"_auth/status_update","params":{"authStatus":{"kind":"account","label":"Claude Max","account":{"plan":"Claude Max","email":"[redacted]","organization":"[redacted]"}}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"The active invoice is INV-1042."},"messageId":"4db28362-ece4-513c-916e-767812de1017"}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Acknowledged."},"messageId":"msg_jp_0744293e3bdf59c7b0006e22f02dbe0c"}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","id":2,"result":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","modes":{"currentModeId":"default","availableModes":[{"id":"default","name":"Manual","description":"Always ask before making changes","_meta":{"kind":"standard"}},{"id":"acceptEdits","name":"Accept edits","description":"Automatically accept all file edits","_meta":{"kind":"standard"}},{"id":"plan","name":"Plan","description":"Create a plan before making changes","_meta":{"kind":"plan"}},{"id":"auto","name":"Auto","description":"Claude handles permission decisions","_meta":{"kind":"auto_review"}},{"id":"bypassPermissions","name":"Bypass permissions","description":"Accepts all permissions","_meta":{"kind":"full_access"}}]},"configOptions":[{"id":"mode","name":"Mode","description":"Session permission mode","category":"mode","type":"select","currentValue":"default","options":[{"value":"default","name":"Manual","description":"Always ask before making changes","_meta":{"kind":"standard"}},{"value":"acceptEdits","name":"Accept edits","description":"Automatically accept all file edits","_meta":{"kind":"standard"}},{"value":"plan","name":"Plan","description":"Create a plan before making changes","_meta":{"kind":"plan"}},{"value":"auto","name":"Auto","description":"Claude handles permission decisions","_meta":{"kind":"auto_review"}},{"value":"bypassPermissions","name":"Bypass permissions","description":"Accepts all permissions","_meta":{"kind":"full_access"}}]},{"id":"model","name":"Model","description":"AI model to use","category":"model","type":"select","currentValue":"default","options":[{"value":"default","name":"Default (recommended)","description":"Opus (1M context)"},{"value":"opus[1m]","name":"Opus 5","description":"Opus 5 with 1M context · Best for everyday, complex tasks"},{"value":"claude-fable-5-1[1m]","name":"Fable 5.1","description":"Fable 5.1 · Most capable for your hardest and longest-running tasks"},{"value":"sonnet","name":"Sonnet 5","description":"Sonnet 5 · Efficient for routine tasks"},{"value":"haiku","name":"Haiku 4.5","description":"Haiku 4.5 · Fastest for quick answers"}]},{"id":"effort","name":"Effort","description":"Available effort levels for this model","category":"thought_level","type":"select","currentValue":"default","options":[{"value":"default","name":"Default"},{"value":"low","name":"Low"},{"value":"medium","name":"Medium"},{"value":"high","name":"High"},{"value":"xhigh","name":"Xhigh"},{"value":"max","name":"Max"}]},{"id":"fast","name":"Fast mode","description":"Faster responses on supported models","category":"model_config","type":"select","currentValue":"off","options":[{"value":"on","name":"On"},{"value":"off","name":"Off"}]}]}}} +{"connection":2,"from":"jp","message":{"jsonrpc":"2.0","id":3,"method":"session/set_config_option","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","configId":"model","value":"claude-opus-5"}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"deep-research","description":"Deep research harness — fan-out web searches, fetch sources, adversarially verify claims, synthesize a cited report. (dynamic workflow)","input":null},{"name":"design-sync","description":"Push a React design system to claude.ai/design. This runs a converter that bundles the real component code (from Storybook or a bare package) and uploads it. Use when the user runs /design-sync or says \"sync my design system to Claude Design\".","input":{"hint":"[]"}},{"name":"dataviz","description":"Use this skill whenever you are about to create ANY chart, graph, plot, dashboard, or data visualization, in ANY output medium — an HTML or React artifact, inline SVG, plotting code in any library (matplotlib, plotly, d3, Recharts, …), an image/PNG you will render and upload, or a chart shared into Slack. Read it BEFORE writing the first line of chart code, choosing chart colors, building a stat tile / meter / KPI row, or laying out a dashboard. When the destination is a first-party document connector (host-designated, never self-described) that renders live charts, hand it the rows (inline, or as an uploaded data file the chart cites) rather than a rendered PNG/SVG — a picture of a chart loses hover, data inspection and per-value comments. Produces visualizations that read as one system — elegant, accessible, consistent in light and dark — using a brand-neutral placeholder palette you swap for your own. Teaches a design-system-agnostic method: a form heuristic, a color formula with a runnable validator, mark specs, and interaction rules. A validated default palette is documented in `references/palette.md` — swap that file's values for your brand's. Triggers on: \"chart\", \"graph\", \"plot\", \"data viz\", \"visualization\", \"dashboard\", \"analytics\", \"visualize data\", \"categorical colors\", \"sequential / diverging palette\", \"stat tile\", \"sparkline\", \"heatmap\", \"legend\", \"axis\", \"tooltip\", \"chart colors\", \"color by series\".","input":null},{"name":"update-config","description":"Use this skill to configure the Claude Code harness via settings.json. Automated behaviors (\"from now on when X\", \"each time X\", \"whenever X\", \"before/after X\") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions (\"allow X\", \"add permission\", \"move permission to\"), env vars (\"set X=Y\"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: \"allow npm commands\", \"add bq permission to global settings\", \"move permission to user settings\", \"set DEBUG=true\", \"when claude stops show X\". For simple settings like theme/model, suggest the /config command.","input":null},{"name":"verify","description":"Verify that a code change actually does what it's supposed to by exercising it end-to-end and observing behavior — drive the affected flow, not just tests or typecheck. Run before committing nontrivial changes; bootstraps this repo's project verify skill if none exists yet. Don't invoke it on a diff that only touches tests, docs, or other code with no runtime surface to drive (a change to product source always has one) — there's nothing to observe.","input":null},{"name":"debug","description":"Enable debug logging for this session and help diagnose issues","input":{"hint":"[issue description]"}},{"name":"code-review","description":"Review the current diff, or a PR number/branch/path target, for correctness bugs and reuse/simplification/efficiency cleanups at the given effort level (low/medium: fewer, high-confidence findings; high→max: broader coverage, may include uncertain findings; ultra: deep multi-agent review in the cloud); with no level given, it reuses the level you typed last. Pass --comment to post findings as inline PR comments, or --fix to apply the findings to the working tree after the review. For ultra on a GitHub.com PR target, --post asks to post the finished review’s findings to the PR as a single comment from the user’s GitHub account (not a review; the launch dialog still confirms in interactive sessions, while non-interactive mode posts on the flag alone) and --no-post hides that option.","input":{"hint":"[low|medium|high|xhigh|max|ultra] [--fix] [--comment] [||]"}},{"name":"simplify","description":"Review the changed code for reuse, simplification, efficiency, and altitude cleanups, then apply the fixes. Quality only — it does not hunt for bugs; use /code-review for that.","input":{"hint":"[]"}},{"name":"batch","description":"Research and plan a large-scale change, then execute it in parallel across 5–30 isolated worktree agents that each open a PR.","input":{"hint":""}},{"name":"fewer-permission-prompts","description":"Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.","input":null},{"name":"doctor","description":"Health-check the user's Claude Code setup and fix issues: diagnose installation health — what the `claude doctor` terminal diagnostics cover — from local data (duplicate or leftover installs, PATH, unparseable settings files, broken or colliding agent definitions, skills whose frontmatter fails to parse); find unused skills, MCP servers, and plugins versus their context cost and disable dead weight; deduplicate local CLAUDE.md files against checked-in ones; trim checked-in CLAUDE.md files by cutting content a session could derive from the codebase (directory layouts, tech-stack lists, architecture overviews) while keeping gotchas, rationale, and non-standard conventions; migrate always-loaded CLAUDE.md guidance into lazy skills and nested CLAUDE.md files; flag slow hooks and context-heavy extensions; check the installed version is current; make auto mode the default permission mode; and pre-approve frequently denied read-only commands. Use when the user asks for a doctor run, checkup, audit, tune-up, or cleanup of their Claude Code setup or configuration.","input":null},{"name":"loop","description":"Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo). Omit the interval to let the model self-pace.","input":{"hint":"[interval] [prompt]"}},{"name":"schedule","description":"Create, update, list, or run scheduled cloud agents (routines) that execute on a cron schedule.","input":null},{"name":"claude-api","description":"Reference for the Claude API / Anthropic SDK — model ids, pricing, params, streaming, tool use, MCP, agents, caching, token counting, model migration.\nTRIGGER — read BEFORE opening the target file; don't skip because it \"looks like a one-liner\" — whenever: the prompt names Claude/Anthropic in any form (Claude, Anthropic, Fable, Opus, Sonnet, Haiku, `anthropic`, `@anthropic-ai`, `claude-*`, `us.anthropic.*`, `[1m]`); the user asks about an LLM (pricing/model choice/limits/caching) — never answer from memory; OR the task is LLM-shaped with provider unstated (agent/MCP/tool-definition/multi-agent/RAG/LLM-judge/computer-use; generate/summarize/extract/classify/rewrite/converse over NL; debugging refusals/cutoffs/streaming/tool-calls/tokens).\nSKIP only when another provider is being worked on (overrides all triggers): OpenAI/GPT/Gemini/Llama/Mistral/Cohere/Ollama named in the query; OR `grep -rE 'openai|langchain_openai|google.generativeai|genai|mistralai|cohere|ollama'` over the project hits (run this grep FIRST if no provider named — don't Read the file).","input":null},{"name":"workflow-authoring","description":"Reference for writing a Workflow tool script (script API and gotchas, resume, quality patterns, worked examples). Load before authoring a script for a workflow the user already opted into; it does not itself authorize running one.","input":null},{"name":"run","description":"Launch and drive this project's app to see a change working. Use when asked to run, start, or screenshot the app, or to confirm a change works in the real app (not just tests). First looks for a project skill that already covers launching the app; otherwise falls back to built-in patterns per project type (CLI, server, TUI, Electron, browser-driven, library).","input":null},{"name":"run-skill-generator","description":"Author or improve the run- skill - a per-project skill that tells agents how to build, launch, and drive this project's app. Use when the user asks to set up the project, get it running, write run instructions, or verify build/run steps work from a clean environment.","input":null},{"name":"agents","description":"(removed) Ask Claude to create/manage subagents, or edit .claude/agents/","input":null},{"name":"auto-mode-setup","description":"Teach auto mode about your environment, plus optional rule tweaks","input":{"hint":"[--request-id ] (--wizard posture=… scope=… depth=… --propose | --expect-sha256 <64-hex> --apply-file )"}},{"name":"autocompact","description":"Configure the auto-compact window size","input":{"hint":"[auto|]"}},{"name":"color","description":"Set the prompt bar color for this session","input":{"hint":"[red|blue|green|yellow|purple|orange|pink|cyan|default]"}},{"name":"compact","description":"Free up context by summarizing the conversation so far","input":{"hint":""}},{"name":"config","description":"Set a setting by key","input":{"hint":"key=value"}},{"name":"context","description":"Show current context usage","input":null},{"name":"effort","description":"Set effort level for model usage","input":{"hint":""}},{"name":"fast","description":"Toggle fast mode (Opus 5)","input":{"hint":"[on|off]"}},{"name":"heapdump","description":"Dump the JS heap to ~/Desktop","input":null},{"name":"init","description":"Initialize a new CLAUDE.md file with codebase documentation","input":null},{"name":"mcp","description":"Manage MCP servers","input":{"hint":"[reconnect|enable|disable [|all]]"}},{"name":"import","description":"Import config from another AI coding agent","input":null},{"name":"model","description":"Set the AI model for Claude Code","input":{"hint":""}},{"name":"__remote-workflow","description":"Run the workflow script delivered in this session environment (server-launched sessions only)","input":null},{"name":"workflow-launch-exec","description":"Execute a server-launched workflow handoff (workflow_launch event sessions only)","input":null},{"name":"reload-skills","description":"Pick up skills added or changed on disk during this session","input":null},{"name":"rename","description":"Rename the current conversation","input":{"hint":"[name]"}},{"name":"ultrareview","description":"Start a cloud agent that finds and verifies bugs in your branch (~5-10 min, $5-$25 USD) · Runs in Claude Code on the web. See https://code.claude.com/docs/en/claude-code-on-the-web","input":null},{"name":"security-review","description":"Complete a security review of the pending changes on the current branch","input":null},{"name":"usage-credits","description":"Configure usage credits or request them from your admin when you hit a limit","input":null},{"name":"extra-usage","description":"Renamed to /usage-credits","input":null},{"name":"usage","description":"Show session cost, plan usage, and what's contributing to your limits","input":null},{"name":"insights","description":"Generate a report analyzing your Claude Code sessions","input":null},{"name":"recap","description":"Generate a one-line session recap now","input":null},{"name":"skill-doctor","description":"Show which loaded skills are unused and costing context","input":null},{"name":"goal","description":"Set a goal — keep working until the condition is met","input":null},{"name":"design","description":"Grant or revoke Claude agent access to your Design projects","input":{"hint":"consent | revoke"}},{"name":"design-consent","description":"Grant Claude agent access to your Design projects","input":null},{"name":"design-revoke","description":"Revoke Claude agent access to your Design projects","input":null},{"name":"list-agents","description":"List subagents, teammates, and other Claude sessions you can message","input":null},{"name":"team-onboarding","description":"Help teammates ramp on Claude Code with a guide from your usage","input":null}]}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"mode","name":"Mode","description":"Session permission mode","category":"mode","type":"select","currentValue":"default","options":[{"value":"default","name":"Manual","description":"Always ask before making changes","_meta":{"kind":"standard"}},{"value":"acceptEdits","name":"Accept edits","description":"Automatically accept all file edits","_meta":{"kind":"standard"}},{"value":"plan","name":"Plan","description":"Create a plan before making changes","_meta":{"kind":"plan"}},{"value":"auto","name":"Auto","description":"Claude handles permission decisions","_meta":{"kind":"auto_review"}},{"value":"bypassPermissions","name":"Bypass permissions","description":"Accepts all permissions","_meta":{"kind":"full_access"}}]},{"id":"model","name":"Model","description":"AI model to use","category":"model","type":"select","currentValue":"opus[1m]","options":[{"value":"default","name":"Default (recommended)","description":"Opus (1M context)"},{"value":"opus[1m]","name":"Opus 5","description":"Opus 5 with 1M context · Best for everyday, complex tasks"},{"value":"claude-fable-5-1[1m]","name":"Fable 5.1","description":"Fable 5.1 · Most capable for your hardest and longest-running tasks"},{"value":"sonnet","name":"Sonnet 5","description":"Sonnet 5 · Efficient for routine tasks"},{"value":"haiku","name":"Haiku 4.5","description":"Haiku 4.5 · Fastest for quick answers"}]},{"id":"effort","name":"Effort","description":"Available effort levels for this model","category":"thought_level","type":"select","currentValue":"default","options":[{"value":"default","name":"Default"},{"value":"low","name":"Low"},{"value":"medium","name":"Medium"},{"value":"high","name":"High"},{"value":"xhigh","name":"Xhigh"},{"value":"max","name":"Max"}]},{"id":"fast","name":"Fast mode","description":"Faster responses on supported models","category":"model_config","type":"select","currentValue":"off","options":[{"value":"on","name":"On"},{"value":"off","name":"Off"}]}]}}} +{"connection":2,"from":"jp","message":{"jsonrpc":"2.0","id":4,"method":"session/set_config_option","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","configId":"mode","value":"default"}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","update":{"sessionUpdate":"current_mode_update","currentModeId":"default"}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"mode","name":"Mode","description":"Session permission mode","category":"mode","type":"select","currentValue":"default","options":[{"value":"default","name":"Manual","description":"Always ask before making changes","_meta":{"kind":"standard"}},{"value":"acceptEdits","name":"Accept edits","description":"Automatically accept all file edits","_meta":{"kind":"standard"}},{"value":"plan","name":"Plan","description":"Create a plan before making changes","_meta":{"kind":"plan"}},{"value":"auto","name":"Auto","description":"Claude handles permission decisions","_meta":{"kind":"auto_review"}},{"value":"bypassPermissions","name":"Bypass permissions","description":"Accepts all permissions","_meta":{"kind":"full_access"}}]},{"id":"model","name":"Model","description":"AI model to use","category":"model","type":"select","currentValue":"opus[1m]","options":[{"value":"default","name":"Default (recommended)","description":"Opus (1M context)"},{"value":"opus[1m]","name":"Opus 5","description":"Opus 5 with 1M context · Best for everyday, complex tasks"},{"value":"claude-fable-5-1[1m]","name":"Fable 5.1","description":"Fable 5.1 · Most capable for your hardest and longest-running tasks"},{"value":"sonnet","name":"Sonnet 5","description":"Sonnet 5 · Efficient for routine tasks"},{"value":"haiku","name":"Haiku 4.5","description":"Haiku 4.5 · Fastest for quick answers"}]},{"id":"effort","name":"Effort","description":"Available effort levels for this model","category":"thought_level","type":"select","currentValue":"default","options":[{"value":"default","name":"Default"},{"value":"low","name":"Low"},{"value":"medium","name":"Medium"},{"value":"high","name":"High"},{"value":"xhigh","name":"Xhigh"},{"value":"max","name":"Max"}]},{"id":"fast","name":"Fast mode","description":"Faster responses on supported models","category":"model_config","type":"select","currentValue":"off","options":[{"value":"on","name":"On"},{"value":"off","name":"Off"}]}]}}} +{"connection":2,"from":"jp","message":{"jsonrpc":"2.0","id":5,"method":"session/prompt","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","prompt":[{"type":"text","text":"Return only the active invoice ID."}]}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","message":{"type":"user","message":{"role":"user","content":"Set model to `opus[1m] (claude-opus-5[1m])`"},"session_id":"589e6947-26f9-4384-97ad-d712b3be5199","parent_tool_use_id":null,"uuid":"b9ae8cd6-4ba5-45a0-9892-dc12908a1f80","timestamp":"2026-09-15T21:39:13.345Z","isReplay":true}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","message":{"type":"system","subtype":"session_state_changed","state":"running","uuid":"6da8608d-7322-4b8a-b421-8ad0144b531f","session_id":"589e6947-26f9-4384-97ad-d712b3be5199"}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","message":{"type":"command_lifecycle","command_uuid":"90517b65-cceb-4499-98e8-8471e18cc005","state":"queued","uuid":"b9079895-3edb-4c83-89d6-e4dbeb1959b1","session_id":"589e6947-26f9-4384-97ad-d712b3be5199"}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","message":{"type":"command_lifecycle","command_uuid":"90517b65-cceb-4499-98e8-8471e18cc005","state":"started","uuid":"f57829e8-48a3-4c8f-b315-7f8747abf027","session_id":"589e6947-26f9-4384-97ad-d712b3be5199"}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","message":{"type":"system","subtype":"init","cwd":"[redacted]","session_id":"589e6947-26f9-4384-97ad-d712b3be5199","tools":[],"mcp_servers":[],"model":"claude-opus-5[1m]","permissionMode":"default","slash_commands":["deep-research","design-sync","dataviz","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","doctor","loop","schedule","claude-api","workflow-authoring","run","run-skill-generator","agents","auto-mode-setup","autocompact","clear","color","compact","config","context","effort","fast","heapdump","init","mcp","import","model","__remote-workflow","workflow-launch-exec","reload-skills","rename","ultrareview","security-review","usage-credits","extra-usage","usage","insights","recap","skill-doctor","goal","design","design-consent","design-revoke","list-agents","team-onboarding"],"terminal_slash_commands":["doctor","color"],"apiKeySource":"none","claude_code_version":"2.1.257","output_style":"default","agents":["claude","Explore","general-purpose","Plan","statusline-setup"],"skills":["deep-research","design-sync","dataviz","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","doctor","loop","schedule","claude-api","workflow-authoring","run","run-skill-generator"],"plugins":[],"capabilities":["interrupt_receipt_v1","interrupt_cancel_queued_v1","msg_lifecycle_v1"],"analytics_disabled":false,"product_feedback_disabled":false,"uuid":"b9115f8f-cf32-4f6f-9537-42924ec97ef3","messaging_socket_path":"/tmp/cc-socks/7763.sock","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required"}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"deep-research","description":"Deep research harness — fan-out web searches, fetch sources, adversarially verify claims, synthesize a cited report. (dynamic workflow)","input":null},{"name":"design-sync","description":"Push a React design system to claude.ai/design. This runs a converter that bundles the real component code (from Storybook or a bare package) and uploads it. Use when the user runs /design-sync or says \"sync my design system to Claude Design\".","input":{"hint":"[]"}},{"name":"dataviz","description":"Use this skill whenever you are about to create ANY chart, graph, plot, dashboard, or data visualization, in ANY output medium — an HTML or React artifact, inline SVG, plotting code in any library (matplotlib, plotly, d3, Recharts, …), an image/PNG you will render and upload, or a chart shared into Slack. Read it BEFORE writing the first line of chart code, choosing chart colors, building a stat tile / meter / KPI row, or laying out a dashboard. When the destination is a first-party document connector (host-designated, never self-described) that renders live charts, hand it the rows (inline, or as an uploaded data file the chart cites) rather than a rendered PNG/SVG — a picture of a chart loses hover, data inspection and per-value comments. Produces visualizations that read as one system — elegant, accessible, consistent in light and dark — using a brand-neutral placeholder palette you swap for your own. Teaches a design-system-agnostic method: a form heuristic, a color formula with a runnable validator, mark specs, and interaction rules. A validated default palette is documented in `references/palette.md` — swap that file's values for your brand's. Triggers on: \"chart\", \"graph\", \"plot\", \"data viz\", \"visualization\", \"dashboard\", \"analytics\", \"visualize data\", \"categorical colors\", \"sequential / diverging palette\", \"stat tile\", \"sparkline\", \"heatmap\", \"legend\", \"axis\", \"tooltip\", \"chart colors\", \"color by series\".","input":null},{"name":"update-config","description":"Use this skill to configure the Claude Code harness via settings.json. Automated behaviors (\"from now on when X\", \"each time X\", \"whenever X\", \"before/after X\") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions (\"allow X\", \"add permission\", \"move permission to\"), env vars (\"set X=Y\"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: \"allow npm commands\", \"add bq permission to global settings\", \"move permission to user settings\", \"set DEBUG=true\", \"when claude stops show X\". For simple settings like theme/model, suggest the /config command.","input":null},{"name":"verify","description":"Verify that a code change actually does what it's supposed to by exercising it end-to-end and observing behavior — drive the affected flow, not just tests or typecheck. Run before committing nontrivial changes; bootstraps this repo's project verify skill if none exists yet. Don't invoke it on a diff that only touches tests, docs, or other code with no runtime surface to drive (a change to product source always has one) — there's nothing to observe.","input":null},{"name":"debug","description":"Enable debug logging for this session and help diagnose issues","input":{"hint":"[issue description]"}},{"name":"code-review","description":"Review the current diff, or a PR number/branch/path target, for correctness bugs and reuse/simplification/efficiency cleanups at the given effort level (low/medium: fewer, high-confidence findings; high→max: broader coverage, may include uncertain findings; ultra: deep multi-agent review in the cloud); with no level given, it reuses the level you typed last. Pass --comment to post findings as inline PR comments, or --fix to apply the findings to the working tree after the review. For ultra on a GitHub.com PR target, --post asks to post the finished review’s findings to the PR as a single comment from the user’s GitHub account (not a review; the launch dialog still confirms in interactive sessions, while non-interactive mode posts on the flag alone) and --no-post hides that option.","input":{"hint":"[low|medium|high|xhigh|max|ultra] [--fix] [--comment] [||]"}},{"name":"simplify","description":"Review the changed code for reuse, simplification, efficiency, and altitude cleanups, then apply the fixes. Quality only — it does not hunt for bugs; use /code-review for that.","input":{"hint":"[]"}},{"name":"batch","description":"Research and plan a large-scale change, then execute it in parallel across 5–30 isolated worktree agents that each open a PR.","input":{"hint":""}},{"name":"fewer-permission-prompts","description":"Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.","input":null},{"name":"loop","description":"Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo). Omit the interval to let the model self-pace.","input":{"hint":"[interval] [prompt]"}},{"name":"schedule","description":"Create, update, list, or run scheduled cloud agents (routines) that execute on a cron schedule.","input":null},{"name":"claude-api","description":"Reference for the Claude API / Anthropic SDK — model ids, pricing, params, streaming, tool use, MCP, agents, caching, token counting, model migration.\nTRIGGER — read BEFORE opening the target file; don't skip because it \"looks like a one-liner\" — whenever: the prompt names Claude/Anthropic in any form (Claude, Anthropic, Fable, Opus, Sonnet, Haiku, `anthropic`, `@anthropic-ai`, `claude-*`, `us.anthropic.*`, `[1m]`); the user asks about an LLM (pricing/model choice/limits/caching) — never answer from memory; OR the task is LLM-shaped with provider unstated (agent/MCP/tool-definition/multi-agent/RAG/LLM-judge/computer-use; generate/summarize/extract/classify/rewrite/converse over NL; debugging refusals/cutoffs/streaming/tool-calls/tokens).\nSKIP only when another provider is being worked on (overrides all triggers): OpenAI/GPT/Gemini/Llama/Mistral/Cohere/Ollama named in the query; OR `grep -rE 'openai|langchain_openai|google.generativeai|genai|mistralai|cohere|ollama'` over the project hits (run this grep FIRST if no provider named — don't Read the file).","input":null},{"name":"workflow-authoring","description":"Reference for writing a Workflow tool script (script API and gotchas, resume, quality patterns, worked examples). Load before authoring a script for a workflow the user already opted into; it does not itself authorize running one.","input":null},{"name":"run","description":"Launch and drive this project's app to see a change working. Use when asked to run, start, or screenshot the app, or to confirm a change works in the real app (not just tests). First looks for a project skill that already covers launching the app; otherwise falls back to built-in patterns per project type (CLI, server, TUI, Electron, browser-driven, library).","input":null},{"name":"run-skill-generator","description":"Author or improve the run- skill - a per-project skill that tells agents how to build, launch, and drive this project's app. Use when the user asks to set up the project, get it running, write run instructions, or verify build/run steps work from a clean environment.","input":null},{"name":"agents","description":"(removed) Ask Claude to create/manage subagents, or edit .claude/agents/","input":null},{"name":"auto-mode-setup","description":"Teach auto mode about your environment, plus optional rule tweaks","input":{"hint":"[--request-id ] (--wizard posture=… scope=… depth=… --propose | --expect-sha256 <64-hex> --apply-file )"}},{"name":"autocompact","description":"Configure the auto-compact window size","input":{"hint":"[auto|]"}},{"name":"compact","description":"Free up context by summarizing the conversation so far","input":{"hint":""}},{"name":"config","description":"Set a setting by key","input":{"hint":"key=value"}},{"name":"context","description":"Show current context usage","input":null},{"name":"effort","description":"Set effort level for model usage","input":{"hint":""}},{"name":"fast","description":"Toggle fast mode (Opus 5)","input":{"hint":"[on|off]"}},{"name":"heapdump","description":"Dump the JS heap to ~/Desktop","input":null},{"name":"init","description":"Initialize a new CLAUDE.md file with codebase documentation","input":null},{"name":"mcp","description":"Manage MCP servers","input":{"hint":"[reconnect|enable|disable [|all]]"}},{"name":"import","description":"Import config from another AI coding agent","input":null},{"name":"model","description":"Set the AI model for Claude Code","input":{"hint":""}},{"name":"__remote-workflow","description":"Run the workflow script delivered in this session environment (server-launched sessions only)","input":null},{"name":"workflow-launch-exec","description":"Execute a server-launched workflow handoff (workflow_launch event sessions only)","input":null},{"name":"reload-skills","description":"Pick up skills added or changed on disk during this session","input":null},{"name":"rename","description":"Rename the current conversation","input":{"hint":"[name]"}},{"name":"ultrareview","description":"Start a cloud agent that finds and verifies bugs in your branch (~5-10 min, $5-$25 USD) · Runs in Claude Code on the web. See https://code.claude.com/docs/en/claude-code-on-the-web","input":null},{"name":"security-review","description":"Complete a security review of the pending changes on the current branch","input":null},{"name":"usage-credits","description":"Configure usage credits or request them from your admin when you hit a limit","input":null},{"name":"extra-usage","description":"Renamed to /usage-credits","input":null},{"name":"usage","description":"Show session cost, plan usage, and what's contributing to your limits","input":null},{"name":"insights","description":"Generate a report analyzing your Claude Code sessions","input":null},{"name":"recap","description":"Generate a one-line session recap now","input":null},{"name":"skill-doctor","description":"Show which loaded skills are unused and costing context","input":null},{"name":"goal","description":"Set a goal — keep working until the condition is met","input":null},{"name":"design","description":"Grant or revoke Claude agent access to your Design projects","input":{"hint":"consent | revoke"}},{"name":"design-consent","description":"Grant Claude agent access to your Design projects","input":null},{"name":"design-revoke","description":"Revoke Claude agent access to your Design projects","input":null},{"name":"list-agents","description":"List subagents, teammates, and other Claude sessions you can message","input":null},{"name":"team-onboarding","description":"Help teammates ramp on Claude Code with a guide from your usage","input":null}]}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","message":{"type":"system","subtype":"status","status":"requesting","session_id":"589e6947-26f9-4384-97ad-d712b3be5199","uuid":"5b579b27-b781-4289-a32b-0eb7901b635a"}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"_auth/status_update","params":{"authStatus":{"kind":"account","label":"Claude Max","account":{"plan":"max","email":"[redacted]","organization":"[redacted]"}}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","message":{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Return only the active invoice ID."}]},"session_id":"589e6947-26f9-4384-97ad-d712b3be5199","parent_tool_use_id":null,"uuid":"90517b65-cceb-4499-98e8-8471e18cc005","timestamp":"2026-09-15T21:39:13.358Z","isReplay":true,"origin":{"kind":"human"}}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","message":{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-opus-5","id":"msg_011Cf5x1mkMHS8F25qRpcfam","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":8920,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null}},"session_id":"589e6947-26f9-4384-97ad-d712b3be5199","parent_tool_use_id":null,"uuid":"3afcdcc2-64dc-4b63-adb7-af9c6955c9a0","ttft_ms":683,"user_message_uuid":"90517b65-cceb-4499-98e8-8471e18cc005"}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","update":{"sessionUpdate":"usage_update","used":8921,"size":1000000}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","message":{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}},"session_id":"589e6947-26f9-4384-97ad-d712b3be5199","parent_tool_use_id":null,"uuid":"1a9c320c-faa4-4f00-83be-59f15679dd6c"}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","message":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"IN"}},"session_id":"589e6947-26f9-4384-97ad-d712b3be5199","parent_tool_use_id":null,"uuid":"44cf3f71-2144-4369-9287-66f633b9effc"}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"IN"},"messageId":"msg_011Cf5x1mkMHS8F25qRpcfam"}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","message":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"V-1042"}},"session_id":"589e6947-26f9-4384-97ad-d712b3be5199","parent_tool_use_id":null,"uuid":"5065f21a-aa65-4d33-b50e-bc6d39f7bb2a"}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"V-1042"},"messageId":"msg_011Cf5x1mkMHS8F25qRpcfam"}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","message":{"type":"assistant","message":{"model":"claude-opus-5","id":"msg_011Cf5x1mkMHS8F25qRpcfam","type":"message","role":"assistant","content":[{"type":"text","text":"INV-1042"}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":8920,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"589e6947-26f9-4384-97ad-d712b3be5199","uuid":"8775be72-cde9-4331-8183-c2b8b078f88a","timestamp":"2026-09-15T21:39:14.466Z","request_id":"req_011Cf5x1mJZ8Wk4EUg19P5hq"}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","message":{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"589e6947-26f9-4384-97ad-d712b3be5199","parent_tool_use_id":null,"uuid":"85aac3a6-34a1-4e14-8902-eaed0f2ee053"}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","message":{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"container":null},"usage":{"input_tokens":8920,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":7,"output_tokens_details":{"thinking_tokens":0},"iterations":[{"input_tokens":8920,"output_tokens":7,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"589e6947-26f9-4384-97ad-d712b3be5199","parent_tool_use_id":null,"uuid":"87afe657-4fbf-4377-bd8b-3bc96d860aee"}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","update":{"sessionUpdate":"usage_update","used":8927,"size":1000000}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","message":{"type":"stream_event","event":{"type":"message_stop"},"session_id":"589e6947-26f9-4384-97ad-d712b3be5199","parent_tool_use_id":null,"uuid":"9f5d8f56-3194-4ce8-91ac-e421bba1d0e1"}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","message":{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1789513800,"rateLimitType":"five_hour","overageStatus":"rejected","overageDisabledReason":"org_level_disabled","isUsingOverage":false,"unifiedWindows":{"five_hour":{"utilization":0.35,"resetsAt":1789513800},"seven_day":{"utilization":0.18,"resetsAt":1789776000}}},"uuid":"e900de6b-194e-477d-8b7f-ef45def10dda","session_id":"589e6947-26f9-4384-97ad-d712b3be5199"}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","update":{"sessionUpdate":"usage_update","used":8927,"size":1000000,"_meta":{"_claude/rateLimit":{"status":"allowed","resetsAt":1789513800,"rateLimitType":"five_hour","overageStatus":"rejected","overageDisabledReason":"org_level_disabled","isUsingOverage":false,"unifiedWindows":{"five_hour":{"utilization":0.35,"resetsAt":1789513800},"seven_day":{"utilization":0.18,"resetsAt":1789776000}}}}}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"_claude/sdkMessage","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","message":{"duration_api_ms":1113,"stop_reason":"end_turn","session_id":"589e6947-26f9-4384-97ad-d712b3be5199","total_cost_usd":0.044775,"usage":{"input_tokens":8920,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":7,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"not_available","iterations":[{"input_tokens":8920,"output_tokens":7,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-opus-5[1m]":{"inputTokens":8920,"outputTokens":7,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.044775,"contextWindow":1000000,"maxOutputTokens":64000,"thinkingTokens":0,"canonicalModel":"claude-opus-5","provider":"firstParty","costBasis":"list"}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","origin":{"kind":"human"},"subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"is_error":false,"num_turns":1,"subtype":"success","api_error_status":null,"result":"INV-1042","ttft_ms":1112,"type":"result","duration_ms":1133,"uuid":"f85abbd7-ecc7-459d-b20e-f578b0aa5674","ttft_stream_ms":698,"time_to_request_ms":16,"user_message_uuid":"90517b65-cceb-4499-98e8-8471e18cc005","request_sent_wall_ms":1789508353370,"queued_turn_count":0}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"589e6947-26f9-4384-97ad-d712b3be5199","update":{"sessionUpdate":"usage_update","used":8927,"size":1000000,"cost":{"amount":0.044775,"currency":"USD"},"_meta":{"_claude/origin":{"kind":"human"}}}}}} +{"connection":2,"from":"agent","message":{"jsonrpc":"2.0","id":5,"result":{"stopReason":"end_turn","usage":{"inputTokens":8920,"outputTokens":7,"cachedReadTokens":0,"cachedWriteTokens":0,"totalTokens":8927},"_meta":{"quota":{"token_count":{"totalTokens":8927,"inputTokens":8920,"cachedInputTokens":0,"cachedWriteTokens":0,"outputTokens":7,"reasoningOutputTokens":0},"model_usage":[{"model":"claude-opus-5[1m]","token_count":{"totalTokens":8927,"inputTokens":8920,"cachedInputTokens":0,"cachedWriteTokens":0,"outputTokens":7,"reasoningOutputTokens":0}}]}}}}} diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_chat_completion_stream__conversation_stream.snap index 61e160a48..07028ef4d 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_chat_completion_stream__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_fable_5_forced_tool_soft_forces__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_fable_5_forced_tool_soft_forces__conversation_stream.snap index 1ea648ca4..90c7375be 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_fable_5_forced_tool_soft_forces__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_fable_5_forced_tool_soft_forces__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_image_attachment__conversation_stream.snap index 35567d9a4..4dfab7653 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_image_attachment__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_multi_turn_conversation__conversation_stream.snap index ecedab3c4..dc0334f20 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_multi_turn_conversation__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_adaptive_thinking__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_adaptive_thinking__conversation_stream.snap index ca7e833ce..d89b2cb35 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_adaptive_thinking__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_adaptive_thinking__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_max_effort__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_max_effort__conversation_stream.snap index 102fbc2e8..a3dd8178f 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_max_effort__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_max_effort__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_redacted_thinking__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_redacted_thinking__conversation_stream.snap index 7483af462..247410da9 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_redacted_thinking__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_redacted_thinking__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_request_chaining__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_request_chaining__conversation_stream.snap index 527919acd..80f804777 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_request_chaining__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_request_chaining__conversation_stream.snap @@ -183,6 +183,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_structured_output__conversation_stream.snap index 582d8ef24..12f0f88b6 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_structured_output__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_auto__conversation_stream.snap index e213bf6f0..7f6be5971 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_auto__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_function__conversation_stream.snap index 23ffc37fa..f89eb6f05 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_function__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_reasoning__conversation_stream.snap index 694348bf9..3a16ee500 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_no_reasoning__conversation_stream.snap index 71f19ba1a..0dda95db4 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_reasoning__conversation_stream.snap index 5106456a0..ca8f5f774 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_stream__conversation_stream.snap index 904072925..6ed6dadef 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_stream__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/anthropic_subscription/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic_subscription/test_chat_completion_stream__conversation_stream.snap index 378d3fe30..01663c30f 100644 --- a/crates/jp_llm/tests/fixtures/anthropic_subscription/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic_subscription/test_chat_completion_stream__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/anthropic_subscription/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic_subscription/test_image_attachment__conversation_stream.snap index 35567d9a4..4dfab7653 100644 --- a/crates/jp_llm/tests/fixtures/anthropic_subscription/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic_subscription/test_image_attachment__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/anthropic_subscription/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic_subscription/test_multi_turn_conversation__conversation_stream.snap index 437e22abf..f7e9c39cc 100644 --- a/crates/jp_llm/tests/fixtures/anthropic_subscription/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic_subscription/test_multi_turn_conversation__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/anthropic_subscription/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic_subscription/test_structured_output__conversation_stream.snap index 97175c68e..2f4fb3e8f 100644 --- a/crates/jp_llm/tests/fixtures/anthropic_subscription/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic_subscription/test_structured_output__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/anthropic_subscription/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic_subscription/test_tool_call_auto__conversation_stream.snap index 7354a8cac..dcd6534bb 100644 --- a/crates/jp_llm/tests/fixtures/anthropic_subscription/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic_subscription/test_tool_call_auto__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/anthropic_subscription/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic_subscription/test_tool_call_function__conversation_stream.snap index 93b0e62f7..ae6bc2ebc 100644 --- a/crates/jp_llm/tests/fixtures/anthropic_subscription/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic_subscription/test_tool_call_function__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/anthropic_subscription/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic_subscription/test_tool_call_reasoning__conversation_stream.snap index c375c37f2..92058ce6d 100644 --- a/crates/jp_llm/tests/fixtures/anthropic_subscription/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic_subscription/test_tool_call_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/anthropic_subscription/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic_subscription/test_tool_call_required_no_reasoning__conversation_stream.snap index 9552fe317..60901c508 100644 --- a/crates/jp_llm/tests/fixtures/anthropic_subscription/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic_subscription/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/anthropic_subscription/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic_subscription/test_tool_call_required_reasoning__conversation_stream.snap index 7f139345d..defe69820 100644 --- a/crates/jp_llm/tests/fixtures/anthropic_subscription/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic_subscription/test_tool_call_required_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/anthropic_subscription/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic_subscription/test_tool_call_stream__conversation_stream.snap index ad00ced7c..dcd181fa1 100644 --- a/crates/jp_llm/tests/fixtures/anthropic_subscription/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic_subscription/test_tool_call_stream__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_chat_completion_stream__conversation_stream.snap index 64666247b..ce40e698c 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_chat_completion_stream__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_multi_turn_conversation__conversation_stream.snap index 82b7bd244..a6cfb10be 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_multi_turn_conversation__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_structured_output__conversation_stream.snap index 2ab41ac0c..71ed280b5 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_structured_output__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_auto__conversation_stream.snap index ba6006d65..1cb5e35d8 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_auto__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_function__conversation_stream.snap index 7c9eb4607..8a202ef5e 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_function__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_reasoning__conversation_stream.snap index eddf1c40e..a15996656 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_no_reasoning__conversation_stream.snap index b56603395..ad9fd0a81 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_reasoning__conversation_stream.snap index cab237f98..fe15b4d40 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_stream__conversation_stream.snap index 10bd6a586..766137be6 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_stream__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_auto_omits_effort__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_auto_omits_effort__conversation_stream.snap index 52067b16c..54f551295 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_auto_omits_effort__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_auto_omits_effort__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_off_sends_none__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_off_sends_none__conversation_stream.snap index 1ddad1723..64ce977d3 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_off_sends_none__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_off_sends_none__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/google/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_chat_completion_stream__conversation_stream.snap index c5cfe1a53..499ebff15 100644 --- a/crates/jp_llm/tests/fixtures/google/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_chat_completion_stream__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/google/test_gemini_3_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_gemini_3_reasoning__conversation_stream.snap index a6f7f9696..9fcab0a05 100644 --- a/crates/jp_llm/tests/fixtures/google/test_gemini_3_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_gemini_3_reasoning__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/google/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_image_attachment__conversation_stream.snap index 04ab3cbc8..95e07b596 100644 --- a/crates/jp_llm/tests/fixtures/google/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_image_attachment__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/google/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_multi_turn_conversation__conversation_stream.snap index 109cf13df..cea01e3b8 100644 --- a/crates/jp_llm/tests/fixtures/google/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_multi_turn_conversation__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/google/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_structured_output__conversation_stream.snap index fe6711925..338052f4a 100644 --- a/crates/jp_llm/tests/fixtures/google/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_structured_output__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_auto__conversation_stream.snap index 2b363ddf4..ccd9f99d7 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_auto__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_function__conversation_stream.snap index f5669e04b..132f239ea 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_function__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_reasoning__conversation_stream.snap index ea14be260..65b3a83e1 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_required_no_reasoning__conversation_stream.snap index f6863cb91..e8c8ce742 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_required_reasoning__conversation_stream.snap index 15476fcaf..9bd891d0f 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_required_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_stream__conversation_stream.snap index 6af3be194..730e60cb5 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_stream__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/google/test_unknown_model_inferred_thinking_level__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_unknown_model_inferred_thinking_level__conversation_stream.snap index a1e8426e6..42b281a66 100644 --- a/crates/jp_llm/tests/fixtures/google/test_unknown_model_inferred_thinking_level__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_unknown_model_inferred_thinking_level__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_chat_completion_stream__conversation_stream.snap index db113896f..cd4f58702 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_chat_completion_stream__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_image_attachment__conversation_stream.snap index 13baefb9a..1bf02729b 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_image_attachment__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_multi_turn_conversation__conversation_stream.snap index 21f149d8e..06e7d6d21 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_multi_turn_conversation__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_structured_output__conversation_stream.snap index 6076c32ca..bdb96edca 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_structured_output__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_auto__conversation_stream.snap index 0a38a90d6..5bf091e6a 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_auto__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_function__conversation_stream.snap index 45e2cf9e6..68dc393ba 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_function__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_reasoning__conversation_stream.snap index b7ce874b7..85a722d62 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_no_reasoning__conversation_stream.snap index 84081a9ff..1f1266230 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_reasoning__conversation_stream.snap index 0431866a4..7e82ff077 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_stream__conversation_stream.snap index 8eb826583..3feba6e4f 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_stream__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_chat_completion_stream__conversation_stream.snap index e85716f2d..0afd5af93 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_chat_completion_stream__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_image_attachment__conversation_stream.snap index 0a8a27ecc..190ce5892 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_image_attachment__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_multi_turn_conversation__conversation_stream.snap index bc5c0840a..a1a531c2c 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_multi_turn_conversation__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_structured_output__conversation_stream.snap index bcce344c3..92d0f5c97 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_structured_output__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_auto__conversation_stream.snap index 44af9d377..d9fa474dc 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_auto__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_function__conversation_stream.snap index 87fce89df..367d0001b 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_function__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_reasoning__conversation_stream.snap index 5f7bf69e6..06955d7e2 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_no_reasoning__conversation_stream.snap index dfce89063..c49910753 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_reasoning__conversation_stream.snap index 12d44c41b..db92af88e 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_stream__conversation_stream.snap index 8e88f74c5..55d5a52cb 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_stream__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openai/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_chat_completion_stream__conversation_stream.snap index 777d07b12..46ba7cda9 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_chat_completion_stream__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_cache_off_sends_explicit_optout__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_cache_off_sends_explicit_optout__conversation_stream.snap index 4e1d01553..7970a17ed 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_cache_off_sends_explicit_optout__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_cache_off_sends_explicit_optout__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_pro_reasoning_and_explicit_caching__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_pro_reasoning_and_explicit_caching__conversation_stream.snap index f9a449b56..208c298f1 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_pro_reasoning_and_explicit_caching__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_pro_reasoning_and_explicit_caching__conversation_stream.snap @@ -182,6 +182,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_prompt_cache_read_after_write__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_prompt_cache_read_after_write__conversation_stream.snap index 8c031666d..728e2717d 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_prompt_cache_read_after_write__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_prompt_cache_read_after_write__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openai/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_image_attachment__conversation_stream.snap index ef69322d9..a584041e7 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_image_attachment__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openai/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_multi_turn_conversation__conversation_stream.snap index 8261422ca..427ad55cc 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_multi_turn_conversation__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openai/test_reasoning_history_replayed_to_reasoning_unsupported_model__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_reasoning_history_replayed_to_reasoning_unsupported_model__conversation_stream.snap index aa88b39f6..ae04d71c3 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_reasoning_history_replayed_to_reasoning_unsupported_model__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_reasoning_history_replayed_to_reasoning_unsupported_model__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openai/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_structured_output__conversation_stream.snap index f4278ec1e..8556ea05b 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_structured_output__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_auto__conversation_stream.snap index f8391d706..f68b18373 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_auto__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_function__conversation_stream.snap index 90ce22ce9..14d0b5ee8 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_function__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_reasoning__conversation_stream.snap index 16dac9796..c5c51d049 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_no_reasoning__conversation_stream.snap index a38724cba..13a5e7799 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_reasoning__conversation_stream.snap index 710486200..c4303bdb5 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_stream__conversation_stream.snap index 2d7644cf9..5488612ae 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_stream__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openai_subscription/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai_subscription/test_chat_completion_stream__conversation_stream.snap index 73870a694..61d6a4aa8 100644 --- a/crates/jp_llm/tests/fixtures/openai_subscription/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai_subscription/test_chat_completion_stream__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openai_subscription/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai_subscription/test_image_attachment__conversation_stream.snap index 8a9cb2314..9f1c961ff 100644 --- a/crates/jp_llm/tests/fixtures/openai_subscription/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai_subscription/test_image_attachment__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openai_subscription/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai_subscription/test_multi_turn_conversation__conversation_stream.snap index 7bd5cfb34..7c8e38a4c 100644 --- a/crates/jp_llm/tests/fixtures/openai_subscription/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai_subscription/test_multi_turn_conversation__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openai_subscription/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai_subscription/test_structured_output__conversation_stream.snap index 7685624c5..342416233 100644 --- a/crates/jp_llm/tests/fixtures/openai_subscription/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai_subscription/test_structured_output__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openai_subscription/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai_subscription/test_tool_call_auto__conversation_stream.snap index 40cd50868..d98a844b4 100644 --- a/crates/jp_llm/tests/fixtures/openai_subscription/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai_subscription/test_tool_call_auto__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openai_subscription/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai_subscription/test_tool_call_function__conversation_stream.snap index bbcbc0b0a..c4153b54f 100644 --- a/crates/jp_llm/tests/fixtures/openai_subscription/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai_subscription/test_tool_call_function__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openai_subscription/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai_subscription/test_tool_call_reasoning__conversation_stream.snap index b1da141c5..9cb63fb5b 100644 --- a/crates/jp_llm/tests/fixtures/openai_subscription/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai_subscription/test_tool_call_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openai_subscription/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai_subscription/test_tool_call_required_no_reasoning__conversation_stream.snap index bfc9385c2..ae99cfd9f 100644 --- a/crates/jp_llm/tests/fixtures/openai_subscription/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai_subscription/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openai_subscription/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai_subscription/test_tool_call_required_reasoning__conversation_stream.snap index 448796c7d..b829632b1 100644 --- a/crates/jp_llm/tests/fixtures/openai_subscription/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai_subscription/test_tool_call_required_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openai_subscription/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai_subscription/test_tool_call_stream__conversation_stream.snap index 4b50ff0db..9e83b4b03 100644 --- a/crates/jp_llm/tests/fixtures/openai_subscription/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai_subscription/test_tool_call_stream__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openrouter/anthropic_test_sub_provider_event_metadata__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/anthropic_test_sub_provider_event_metadata__conversation_stream.snap index 1980431e9..543bbeacb 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/anthropic_test_sub_provider_event_metadata__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/anthropic_test_sub_provider_event_metadata__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openrouter/google_test_sub_provider_event_metadata__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/google_test_sub_provider_event_metadata__conversation_stream.snap index b78bf76c1..bd66a9bd1 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/google_test_sub_provider_event_metadata__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/google_test_sub_provider_event_metadata__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openrouter/minimax_test_sub_provider_event_metadata__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/minimax_test_sub_provider_event_metadata__conversation_stream.snap index b75ea88c7..5a6f4b696 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/minimax_test_sub_provider_event_metadata__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/minimax_test_sub_provider_event_metadata__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_anthropic_opus_5_parallel_tool_round_trip__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_anthropic_opus_5_parallel_tool_round_trip__conversation_stream.snap index 919b4353c..1c027de97 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_anthropic_opus_5_parallel_tool_round_trip__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_anthropic_opus_5_parallel_tool_round_trip__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_chat_completion_stream__conversation_stream.snap index 9903ffc31..7e31cbc9b 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_chat_completion_stream__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_image_attachment__conversation_stream.snap index cb7a58aae..0237b55f3 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_image_attachment__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_multi_turn_conversation__conversation_stream.snap index 9c2f302ff..6789b6c3d 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_multi_turn_conversation__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_structured_output__conversation_stream.snap index 740deeb40..950564947 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_structured_output__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_auto__conversation_stream.snap index cbcd6e13c..08633a3c1 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_auto__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_function__conversation_stream.snap index c1e0379bc..516656872 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_function__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_reasoning__conversation_stream.snap index 57596a133..5bf2d892d 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_no_reasoning__conversation_stream.snap index b7846fa68..679e3a7f6 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_reasoning__conversation_stream.snap index 98b810bca..d414224fc 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_stream__conversation_stream.snap index 021f2a170..78f3833c4 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_stream__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/openrouter/x-ai_test_sub_provider_event_metadata__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/x-ai_test_sub_provider_event_metadata__conversation_stream.snap index 9257f9e63..642f48fac 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/x-ai_test_sub_provider_event_metadata__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/x-ai_test_sub_provider_event_metadata__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/vllm/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/vllm/test_chat_completion_stream__conversation_stream.snap index 13d345975..419badc5b 100644 --- a/crates/jp_llm/tests/fixtures/vllm/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/vllm/test_chat_completion_stream__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/vllm/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/vllm/test_image_attachment__conversation_stream.snap index 66a84bca6..43c8bc4e1 100644 --- a/crates/jp_llm/tests/fixtures/vllm/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/vllm/test_image_attachment__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/vllm/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/vllm/test_multi_turn_conversation__conversation_stream.snap index c9d1875c9..778286ba2 100644 --- a/crates/jp_llm/tests/fixtures/vllm/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/vllm/test_multi_turn_conversation__conversation_stream.snap @@ -181,6 +181,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/vllm/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/vllm/test_structured_output__conversation_stream.snap index eb9948a29..741766e23 100644 --- a/crates/jp_llm/tests/fixtures/vllm/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/vllm/test_structured_output__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/vllm/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/vllm/test_tool_call_auto__conversation_stream.snap index 101e5e78c..4334a62f1 100644 --- a/crates/jp_llm/tests/fixtures/vllm/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/vllm/test_tool_call_auto__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/vllm/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/vllm/test_tool_call_function__conversation_stream.snap index 4f6439477..3756cfe19 100644 --- a/crates/jp_llm/tests/fixtures/vllm/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/vllm/test_tool_call_function__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/vllm/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/vllm/test_tool_call_reasoning__conversation_stream.snap index 9df729283..6d0b2f16b 100644 --- a/crates/jp_llm/tests/fixtures/vllm/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/vllm/test_tool_call_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/vllm/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/vllm/test_tool_call_required_no_reasoning__conversation_stream.snap index 5c5a8df9d..e06b84d1d 100644 --- a/crates/jp_llm/tests/fixtures/vllm/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/vllm/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/vllm/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/vllm/test_tool_call_required_reasoning__conversation_stream.snap index e52a21f26..77950b575 100644 --- a/crates/jp_llm/tests/fixtures/vllm/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/vllm/test_tool_call_required_reasoning__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_llm/tests/fixtures/vllm/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/vllm/test_tool_call_stream__conversation_stream.snap index 91b284fb3..11bd3d71c 100644 --- a/crates/jp_llm/tests/fixtures/vllm/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/vllm/test_tool_call_stream__conversation_stream.snap @@ -178,6 +178,7 @@ expression: v "auth": [ "api_key" ], + "subscription_flow": "acp", "api_key_env": "ANTHROPIC_API_KEY", "base_url": "https://api.anthropic.com", "chain_on_max_tokens": true, diff --git a/crates/jp_mcp/Cargo.toml b/crates/jp_mcp/Cargo.toml index f8b26a929..4ab8f6daa 100644 --- a/crates/jp_mcp/Cargo.toml +++ b/crates/jp_mcp/Cargo.toml @@ -12,20 +12,71 @@ readme.workspace = true repository.workspace = true version.workspace = true +[features] +default = ["client"] + +# Connect to the MCP servers named in `providers.mcp`. +client = ["rmcp/client", "rmcp/transport-child-process", "rmcp/transport-io"] + +# Run JP's tools: local commands, built-ins, and the tools those MCP servers +# declare. Implies `client`, because running an MCP tool means calling one. +server = [ + "client", + "dep:async-trait", + "dep:axum", + "dep:base64", + "dep:camino", + "dep:futures", + "dep:jp_tool", + "dep:minijinja", + "dep:reqwest", + "dep:sse-stream", + "dep:tokio-util", + "dep:url", + "rmcp/server", + "rmcp/transport-streamable-http-server", + "rmcp/transport-streamable-http-client", +] + [dependencies] jp_config = { workspace = true } +jp_tool = { workspace = true, optional = true } +async-trait = { workspace = true, optional = true } +axum = { workspace = true, optional = true, features = ["http1", "tokio"] } +base64 = { workspace = true, optional = true, features = ["std"] } +camino = { workspace = true, optional = true } +futures = { workspace = true, optional = true } indexmap = { workspace = true } -rmcp = { workspace = true, features = ["client", "transport-child-process", "transport-io"] } +minijinja = { workspace = true, optional = true, features = [ + "builtins", + "json", + "preserve_order", + "serde", + "unicode", +] } +reqwest = { workspace = true, optional = true, features = ["json", "stream"] } +rmcp = { workspace = true } serde = { workspace = true } serde_json = { workspace = true, features = ["preserve_order"] } sha1 = { workspace = true } sha2 = { workspace = true } +sse-stream = { workspace = true, optional = true } thiserror = { workspace = true } tokio = { workspace = true } +tokio-util = { workspace = true, optional = true } tracing = { workspace = true } +url = { workspace = true, optional = true } which = { workspace = true } +[dev-dependencies] +assert_matches = { workspace = true } +camino-tempfile = { workspace = true } +# The conformance client speaks JSON-RPC and SSE over plain HTTP by hand, on +# purpose: it must not share a transport with the endpoint it is checking. +reqwest = { workspace = true, features = ["json"] } +tokio = { workspace = true, features = ["test-util"] } + [lints] workspace = true diff --git a/crates/jp_mcp/README.md b/crates/jp_mcp/README.md index 5d02456ac..47ed05ff8 100644 --- a/crates/jp_mcp/README.md +++ b/crates/jp_mcp/README.md @@ -1,4 +1,76 @@ -# Model-Context-Protocol (MCP) Client +# Model Context Protocol (MCP) -This crate provides a client to handle multiple Model-Context-Protocol (MCP) -servers. +The default `client` feature manages configured upstream stdio MCP servers. +The `server` feature also enables tool resolution, local command execution, and +the built-in tool registry. + +`server::service::Service` manages individual calls against an immutable tool +catalog and working context. +Its private Host receiver carries admission, execution release, input, result +review, and recording requests. +The MCP Host must service those requests while calls run. +Questions finish an execution attempt; answers trigger a new attempt with +accumulated input. + +Calls have independent cancellation tokens. +Dropping a result receiver does not cancel or retry a call. +`cancel_current` stops current work while allowing later calls; `shutdown` stops +admission, cancels calls, waits for cleanup, and closes owned upstream +connections. +Stderr progress uses a separate bounded channel. + +`server::http::Endpoint` exposes the service through MCP Streamable HTTP on an +OS-assigned loopback port. +It validates Host and supplied Origin headers. +Its `connect` method creates an ordinary MCP client connection through that HTTP +endpoint; the private Host channel remains separate. + +The MCP Host can set `ConfiguredTool.metadata` before starting the service. +It is advertised as each tool's `_meta` object, including opaque result-size +hints for external clients. +Incoming call metadata remains correlation data; it cannot change these +descriptions, execution context, options, or answers. + +Upstream stdio calls carry trusted execution context and accumulated answers +under `_meta["computer.jp/tool"]` and `_meta["computer.jp/context"]`. +Single text results are recognized as legacy `Outcome` envelopes when their +shape matches. +Mixed native content and result metadata are retained in `jp_tool::ToolResult`. +Execution, Host review, and recording carry that ordered representation; the +HTTP handler converts it back to MCP content after recording is acknowledged. +The CLI explicitly projects it to the existing text/error conversation format. +An unchanged review retains resources, annotations, structured content, and +metadata; a text edit replaces the delivered content. + +The ordinary CLI query runner submits calls through the HTTP endpoint. +Its executor adapter holds pending Host replies across preparation, release, +input, and result review. +After the conversation owner flushes the recorded response, the adapter +acknowledges final delivery and consumes the MCP response. + +The Host connection disables environment proxies, redirects, and transparent +session reinitialization. +It does not resubmit a tool call on transport failure. +`http_client` implements rmcp's HTTP-client trait using the workspace Reqwest +version. +The rmcp worker owns MCP sessions and SSE resumption; the adapter does not +implement another request retry loop. +The HTTP endpoint has no authentication; its loopback binding and header checks +are not a claim that the caller is a particular local application. + +## Conformance checks + +The server tests include an independent JSON-RPC/SSE client, without using +`Endpoint::connect` for third-party calls. +A scripted MCP Host handles approval, input, result editing, and recording +through the private channel. +The tests exercise concurrent callers, scoped cancellation, failed recording, +large results, and resumption through `Last-Event-ID` after dropping an HTTP +response. +They also check that an upstream result's native content and metadata survive +the Host's text projection. + +These tests do not launch Claude Code, consume subscription quota, or guarantee +exactly-once execution after a process crash. +Agent-specific correlation and configuration belong to the integration consuming +this service. diff --git a/crates/jp_mcp/src/client.rs b/crates/jp_mcp/src/client.rs index 2850a7300..a545054ef 100644 --- a/crates/jp_mcp/src/client.rs +++ b/crates/jp_mcp/src/client.rs @@ -11,7 +11,7 @@ use indexmap::IndexMap; use jp_config::providers::mcp::{AlgorithmConfig, McpProviderConfig}; use rmcp::{ model::{ - CallToolRequestParams, CallToolResult, ReadResourceRequestParams, Resource, + CallToolRequestParams, CallToolResult, Meta, ReadResourceRequestParams, Resource, ResourceContents, Tool, }, service::{RoleClient, RunningService, ServiceExt}, @@ -212,6 +212,7 @@ impl Client { tool_name: &str, server_name: &str, params: &serde_json::Value, + meta: Option>, ) -> Result { let server_id = McpServerId::new(server_name); let services = self.services.read().await; @@ -221,6 +222,7 @@ impl Client { let mut call_params = CallToolRequestParams::new(tool_name.to_owned()); call_params.arguments = params.as_object().cloned(); + call_params.meta = meta.filter(|meta| !meta.is_empty()).map(Meta); client .peer() @@ -260,6 +262,26 @@ impl Client { .contents) } + /// Close the owned upstream connections and wait for their service tasks. + /// + /// Callers must stop admitting work before shutdown. + /// All clones share these connections, so this also disconnects users of a + /// cloned client. + pub async fn shutdown(&self) { + let services = { + let mut services = self.services.write().await; + services + .drain() + .map(|(_, service)| service) + .collect::>() + }; + for service in services { + if let Err(error) = service.cancel().await { + warn!(%error, "MCP service failed during shutdown"); + } + } + } + pub async fn run_services( &mut self, server_ids: HashSet, @@ -649,6 +671,10 @@ fn spawn_stderr_forwarder( #[path = "client_tests.rs"] mod tests; +#[cfg(all(test, feature = "server"))] +#[path = "client_protocol_tests.rs"] +mod protocol_tests; + pub fn verify_file_checksum( server: &str, command: &Path, diff --git a/crates/jp_mcp/src/client_protocol_tests.rs b/crates/jp_mcp/src/client_protocol_tests.rs new file mode 100644 index 000000000..df172a08d --- /dev/null +++ b/crates/jp_mcp/src/client_protocol_tests.rs @@ -0,0 +1,342 @@ +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use indexmap::IndexMap; +use jp_config::{ + AppConfig, Config as _, + conversation::tool::{PartialToolConfig, ToolConfig}, + providers::mcp::{McpProviderConfig, StdioConfig}, +}; +use jp_tool::{ContentBlock, InvocationContext, Outcome, Question, ToolDefinition, ToolDocs}; +use rmcp::{ + ErrorData, ServerHandler, + model::{ + CallToolRequestParams, CallToolResult, ListToolsResult, PaginatedRequestParams, + ServerCapabilities, ServerInfo, Tool, + }, + service::{RequestContext, RoleServer, ServiceExt as _}, +}; +use serde_json::{Map, Value, json}; +use tokio::{ + io::duplex, + time::{Duration, timeout}, +}; +use tokio_util::sync::CancellationToken; + +use super::{Client, McpServerId}; +use crate::{ + Content, + server::{ + Answers, Execution, ExecutionOutcome, + builtin::BuiltinExecutors, + execute, + http::Endpoint, + service::{Admission, ConfiguredTool, Interaction, ReleaseDecision, Service}, + tool_definitions, + }, +}; + +struct Upstream(Arc); + +impl ServerHandler for Upstream { + fn get_info(&self) -> ServerInfo { + let mut info = ServerInfo::default(); + info.capabilities = ServerCapabilities::builder().enable_tools().build(); + info + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + assert_eq!(request.name, "actual_tool"); + assert_eq!( + context.meta.0["computer.jp/tool"]["arguments"], + Value::Object(request.arguments.unwrap()) + ); + let outcome = if context.meta.0["computer.jp/tool"]["answers"] + .get("confirm") + .is_some() + { + Outcome::Success { + content: serde_json::to_string(&context.meta.0).unwrap(), + } + } else { + Question::boolean("confirm", "Continue?").unwrap().into() + }; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string(&outcome).unwrap(), + )])) + } +} + +#[tokio::test] +async fn upstream_receives_context_options_and_accumulated_answers() { + let count = Arc::new(AtomicUsize::new(0)); + let (client_transport, server_transport) = duplex(8192); + let handler = Upstream(count.clone()); + let server = tokio::spawn(async move { handler.serve(server_transport).await.unwrap() }); + let running = ().serve(client_transport).await.unwrap(); + let server = server.await.unwrap(); + let client = Client::default(); + client + .services + .write() + .await + .insert(McpServerId::new("upstream"), running); + let partial: PartialToolConfig = + serde_json::from_value(json!({"source":"mcp.upstream.actual_tool", "options":{"limit":7}})) + .unwrap(); + let mut cfg = AppConfig::new_test(); + cfg.conversation.tools.insert( + "alias".into(), + ToolConfig::from_partial(partial, vec![]).unwrap(), + ); + let config = cfg.conversation.tools.get("alias").unwrap(); + let definition = ToolDefinition { + name: "alias".into(), + docs: ToolDocs::default(), + parameters: json!({"type":"object","properties":{"value":{"type":"string"}}}), + }; + let invocation = InvocationContext { + workspace_id: "workspace-1".into(), + conversation_id: "conversation-1".into(), + }; + let builtins = BuiltinExecutors::new(); + // One context for both attempts, which is what the service does: the second + // attempt differs only by the answer it carries. + let execution = Execution { + definition: &definition, + id: "call-1".into(), + arguments: json!({"value":"edited"}), + config: &config, + root: "/work".into(), + access: None, + invocation: &invocation, + builtins: &builtins, + upstream: &client, + cancellation: CancellationToken::new(), + stderr: None, + }; + let first = execute(&execution, &Answers::new()).await.unwrap(); + let ExecutionOutcome::NeedsInput { question, .. } = first else { + panic!("expected decoded question") + }; + assert_eq!(question, Question::boolean("confirm", "Continue?").unwrap()); + let answers = Answers::from_iter([("confirm".into(), json!(true))]); + let second = execute(&execution, &answers).await.unwrap(); + let ExecutionOutcome::Completed { result, .. } = second else { + panic!("expected final result") + }; + assert!(!result.is_error()); + assert_eq!( + serde_json::from_str::(&result.to_text()).unwrap(), + json!({ + "computer.jp/tool":{"name":"actual_tool", "arguments":{"value":"edited"}, "answers":{"confirm":true}, "options":{"limit":7}}, + "computer.jp/context":{"action":"run", "root":"/work", "access":null, "workspace_id":"workspace-1", "conversation_id":"conversation-1"}, + "progressToken":1 + }) + ); + assert_eq!(count.load(Ordering::SeqCst), 2); + client.shutdown().await; + server.cancel().await.unwrap(); +} + +struct NativeUpstream(Arc); + +impl ServerHandler for NativeUpstream { + fn get_info(&self) -> ServerInfo { + let mut info = ServerInfo::default(); + info.capabilities = ServerCapabilities::builder().enable_tools().build(); + info + } + + async fn list_tools( + &self, + _: Option, + _: RequestContext, + ) -> Result { + Ok(ListToolsResult { + tools: vec![Tool::new( + "native", + "Native result", + Arc::new( + json!({"type":"object","properties":{}}) + .as_object() + .unwrap() + .clone(), + ), + )], + ..Default::default() + }) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _: RequestContext, + ) -> Result { + assert_eq!(request.name, "native"); + self.0.fetch_add(1, Ordering::SeqCst); + Ok(serde_json::from_value(json!({ + "content":[{"type":"text","text":"alpha"},{"type":"image","data":"AA==","mimeType":"image/png"},{"type":"resource","resource":{"uri":"fixture:///resource","text":"resource","mimeType":"text/plain"}}], + "isError":false,"structuredContent":{"answer":42},"_meta":{"fixture/source":"upstream"} + })).unwrap()) + } +} + +#[tokio::test] +#[expect( + clippy::too_many_lines, + reason = "One upstream call read end to end, from its wire result to the caller's" +)] +async fn native_upstream_result_survives_host_projection_and_http_delivery() { + timeout(Duration::from_secs(10), async { + let count = Arc::new(AtomicUsize::new(0)); + // Use the stdio codec without starting an extra fixture executable. + let (client_transport, server_transport) = duplex(8192); + let handler = NativeUpstream(count.clone()); + let server = tokio::spawn(async move { handler.serve(server_transport).await.unwrap() }); + let running = ().serve(client_transport).await.unwrap(); + let server = server.await.unwrap(); + + let upstream = Client::new(IndexMap::from_iter([( + "upstream".into(), + McpProviderConfig::Stdio(StdioConfig { + command: "unused-fixture".into(), + arguments: vec![], + variables: vec![], + checksum: None, + optional: false, + startup_timeout_secs: 60, + }), + )])); + upstream + .services + .write() + .await + .insert(McpServerId::new("upstream"), running); + + let mut cfg = AppConfig::new_test(); + let partial: PartialToolConfig = serde_json::from_value(json!({ + "source": "mcp.upstream.native", + "run": "unattended", + "result": "ask", + })) + .unwrap(); + cfg.conversation.tools.insert( + "alias".into(), + ToolConfig::from_partial(partial, vec![]).unwrap(), + ); + let definitions = tool_definitions(cfg.conversation.tools.iter(), &upstream, None) + .await + .unwrap(); + let configured = definitions + .into_iter() + .map(|definition| ConfiguredTool { + config: cfg.conversation.tools.get(&definition.name).unwrap(), + definition, + access: Ok(None), + metadata: Map::new(), + }) + .collect(); + let (service, mut host) = Service::new( + configured, + upstream, + BuiltinExecutors::new(), + "/work".into(), + InvocationContext::default(), + ) + .unwrap(); + + let endpoint = Endpoint::start(service).await.unwrap(); + let client = endpoint.connect().await.unwrap(); + let peer = client.peer().clone(); + let result = tokio::spawn(async move { + peer.call_tool(CallToolRequestParams::new("alias")) + .await + .unwrap() + }); + + let Interaction::Prepare { + arguments, reply, .. + } = host.recv().await.unwrap().interaction + else { + panic!("expected preparation") + }; + reply.send(Ok(Admission::Run { arguments })).unwrap(); + + let Interaction::Release { reply, .. } = host.recv().await.unwrap().interaction else { + panic!("expected release") + }; + reply.send(Ok(ReleaseDecision::Execute)).unwrap(); + + // Everything the upstream server sent reaches the Host intact: the + // image block, the structured data, and the result metadata. + let Interaction::Review { + result: reviewed, + reply, + .. + } = host.recv().await.unwrap().interaction + else { + panic!("expected review") + }; + assert!(matches!( + &reviewed.content[1], + ContentBlock::Image(image) + if image.data == "AA==" && image.mime_type == "image/png" + )); + assert_eq!(reviewed.structured_content, Some(json!({"answer": 42}))); + assert_eq!( + reviewed.metadata, + Some( + json!({"fixture/source": "upstream"}) + .as_object() + .unwrap() + .clone() + ) + ); + reply.send(Ok(reviewed.clone())).unwrap(); + + let Interaction::Record { recording, reply } = host.recv().await.unwrap().interaction + else { + panic!("expected recording") + }; + assert_eq!(recording.result, reviewed); + assert_eq!(recording.raw_result, Some(reviewed)); + // The conversation stores only the text, which is what makes the + // assertion below worth making. + assert_eq!(recording.result.to_text(), "alpha\n\nresource"); + assert!(!reply.is_closed()); + reply.send(Ok(())).unwrap(); + + assert_eq!( + serde_json::to_value(result.await.unwrap()).unwrap(), + json!({ + "content": [ + {"type": "text", "text": "alpha"}, + {"type": "image", "data": "AA==", "mimeType": "image/png"}, + {"type": "resource", "resource": { + "uri": "fixture:///resource", + "text": "resource", + "mimeType": "text/plain", + }}, + ], + "isError": false, + "structuredContent": {"answer": 42}, + "_meta": {"fixture/source": "upstream"}, + }) + ); + assert_eq!(count.load(Ordering::SeqCst), 1); + + client.cancel().await.unwrap(); + endpoint.shutdown().await.unwrap(); + server.cancel().await.unwrap(); + }) + .await + .unwrap(); +} diff --git a/crates/jp_mcp/src/lib.rs b/crates/jp_mcp/src/lib.rs index 0c9175527..46949fa01 100644 --- a/crates/jp_mcp/src/lib.rs +++ b/crates/jp_mcp/src/lib.rs @@ -1,9 +1,21 @@ -//! MCP (Model Context Protocol) client integration for JP. +//! MCP (Model Context Protocol) integration for JP. +//! +//! Two halves, separately selectable: +//! +//! - `client` connects to the MCP servers named in `providers.mcp`. +//! - `server` runs JP's tools, whatever their source, and needs the client to +//! reach the MCP-backed ones. +#[cfg(feature = "client")] mod client; +#[cfg(feature = "client")] pub mod error; pub mod id; +#[cfg(feature = "server")] +pub mod server; +#[cfg(feature = "client")] pub use client::{Client, Startup, StartupSet, StderrLine}; +#[cfg(feature = "client")] pub use error::Error; pub use rmcp::model::{CallToolResult, Content, RawContent, ResourceContents, Tool}; diff --git a/crates/jp_mcp/src/server.rs b/crates/jp_mcp/src/server.rs new file mode 100644 index 000000000..79b210976 --- /dev/null +++ b/crates/jp_mcp/src/server.rs @@ -0,0 +1,1108 @@ +//! JP tool execution and MCP serving. +//! +//! [`service::Service`] coordinates tool execution with the MCP Host through +//! private interaction channels. +//! [`http::Endpoint`] exposes that service over loopback Streamable HTTP. +//! +//! [`tool_definitions`] resolves the configured catalog. +//! [`execute`] runs one attempt of a local command, built-in implementation, or +//! upstream stdio MCP tool; the service handles input-driven re-execution and +//! delivery barriers. + +pub mod builtin; +pub mod http; +mod http_client; +pub mod json_schema; +pub mod result; +pub mod service; +mod upstream; +use std::{ffi::OsStr, fmt, process::Stdio, sync::Arc}; + +pub use builtin::BuiltinTool; +use camino::Utf8Path; +use indexmap::IndexMap; +use jp_config::{ + conversation::tool::{CommandConfig, ToolConfigWithDefaults, ToolSource}, + types::command::shell_command_line, +}; +use jp_tool::{ + AccessPolicy, Action, Error as ToolError, InvocationContext, Outcome, ParameterDocs, Question, + ToolDefinition, ToolDocs, ToolResult, + content::{ErrorDetails, ToolStatus}, + definition::{apply_parameter_defaults, split_description, validate_tool_arguments}, + schema::{Node, merge_description}, +}; +use minijinja::{Environment, ErrorKind as MinijinjaErrorKind, value::ValueKind}; +use result::from_mcp; +use serde_json::{Error as JsonError, Map, Value, json}; +use tokio::{ + io::{AsyncBufReadExt, AsyncReadExt, BufReader}, + process::Command, +}; +use tokio_util::sync::CancellationToken; +use tracing::{error, info, trace, warn}; +use upstream::{UpstreamResult, decode_result, replace_envelope}; + +use crate::{ + Client, + id::{McpServerId, McpToolId}, +}; + +/// Build trusted execution context for local templates and upstream MCP +/// metadata. +pub(crate) fn tool_context( + name: &str, + arguments: &Value, + answers: &IndexMap, + config: &ToolConfigWithDefaults, + root: &Utf8Path, + action: &Action, + access: Option<&AccessPolicy>, + invocation: &InvocationContext, +) -> Value { + json!({ + "tool": { "name":name, "arguments":arguments, "answers":answers, "options":config.options() }, + "context": { "action":action, "root":root.as_str(), "access":access, + "workspace_id":invocation.workspace_id, "conversation_id":invocation.conversation_id } + }) +} + +/// Read a tool's documentation out of its configuration. +fn tool_docs_from_config(config: &ToolConfigWithDefaults) -> ToolDocs { + let parameters = config + .parameters() + .iter() + .filter_map(|(param_name, param_cfg)| { + let summary = param_cfg + .summary + .as_deref() + .or(param_cfg.description.as_deref()) + .map(str::to_owned); + let desc = param_cfg.description.as_deref().map(str::to_owned); + let ex = param_cfg.examples.as_deref().map(str::to_owned); + + if summary.is_none() && desc.is_none() && ex.is_none() { + return None; + } + + Some((param_name.to_owned(), ParameterDocs { + summary, + description: desc, + examples: ex, + })) + }) + .collect(); + + ToolDocs { + summary: config.summary().map(str::to_owned), + description: config.description().map(str::to_owned), + examples: config.examples().map(str::to_owned), + parameters, + } +} + +/// The outcome of a tool execution. +/// +/// This type represents the possible results of executing a tool's underlying +/// command or MCP call, without any interactive prompts. +/// The caller is responsible for: +/// +/// 1. Handling permission prompts **before** calling [`execute()`]. +/// 2. Handling [`ExecutionOutcome::NeedsInput`] by prompting the user or +/// assistant. +/// 3. Handling result editing **after** receiving the outcome. +#[derive(Debug)] +pub enum ExecutionOutcome { + /// Tool executed and produced a result. + Completed { + /// The tool call ID (for correlation with the request). + id: String, + + /// The execution result. + /// + /// If an error occurred, it means the tool ran, but reported an error. + result: ToolResult, + }, + + /// Tool needs additional input before it can complete. + /// + /// The caller should: + /// + /// 1. Present the question to the user (or delegate to the assistant) + /// 2. Collect the answer + /// 3. Call [`execute()`] again with the answer in `answers` + NeedsInput { + /// The tool call ID. + id: String, + + /// The question to ask. + question: Question, + }, + + /// Tool execution was cancelled via the cancellation token. + /// + /// This occurs when the user interrupts tool execution (e.g., Ctrl+C during + /// a long-running command). + Cancelled { + /// The tool call ID. + id: String, + }, +} + +impl ExecutionOutcome { + /// Returns the tool call ID. + #[must_use] + pub fn id(&self) -> &str { + match self { + Self::Completed { id, .. } | Self::NeedsInput { id, .. } | Self::Cancelled { id } => id, + } + } + + /// Returns `true` if this is a `NeedsInput` outcome. + #[must_use] + pub fn needs_input(&self) -> bool { + matches!(self, Self::NeedsInput { .. }) + } + + /// Returns `true` if this is a `Cancelled` outcome. + #[must_use] + pub fn is_cancelled(&self) -> bool { + matches!(self, Self::Cancelled { .. }) + } + + /// Returns `true` if this is a `Completed` outcome with a successful + /// result. + #[must_use] + pub fn is_success(&self) -> bool { + matches!(self, Self::Completed { result, .. } if !result.is_error()) + } +} + +/// Result of running a tool command. +/// +/// This is the single parsing point for all tool command output. +/// Both tool execution and argument formatting go through this type, ensuring +/// consistent handling of `Outcome` variants (including error traces). +#[derive(Debug)] +pub enum CommandResult { + /// Tool produced content. + Success(String), + + /// Tool reported a transient error (can be retried). + TransientError { + /// The error message. + message: String, + + /// The error trace (source chain from the tool process). + trace: Vec, + }, + + /// Tool reported a fatal error. + FatalError { + /// Original error envelope, retained for the current conversation + /// format. + raw: String, + /// Source chain reported by the tool. + trace: Vec, + }, + + /// Tool needs additional input before it can continue. + NeedsInput(Question), + + /// Tool was cancelled via the cancellation token. + Cancelled, + + /// stdout wasn't valid `Outcome` JSON. + /// + /// Falls back to treating stdout as plain text. + /// The `success` flag indicates the process exit status. + RawOutput { + /// Raw stdout content. + stdout: String, + + /// Raw stderr content. + stderr: String, + + /// Whether the process exited successfully. + success: bool, + }, + + /// Tool emitted a well-formed `needs_input` whose question id is invalid + /// (empty, or contains a `.`, which is reserved as the inquiry-id + /// separator). + /// + /// Surfaced as a tool-level error so the malformed inquiry is dropped + /// before any inquiry event is constructed. + InvalidInquiry { + /// The offending question id, for the diagnostic trace. + question_id: String, + }, + + /// Tool emitted a payload shaped like a `needs_input` outcome (top-level + /// `"type": "needs_input"`) that failed to deserialize for a reason other + /// than an invalid question id: a field with the wrong shape, a missing + /// field, or a local-tool binary emitting an older wire protocol than this + /// build parses. + /// + /// Surfaced as a tool-level error rather than [`Self::RawOutput`] so a + /// protocol mismatch is loud, instead of silently handing the raw JSON to + /// the model as tool output. + MalformedInquiry { + /// The deserialization error, for the diagnostic trace and the + /// model-facing message. + detail: JsonError, + }, +} + +impl CommandResult { + /// Format a transient error message including trace details. + /// + /// If the trace is empty, returns just the message. + /// Otherwise appends the trace entries so the LLM (or user) can see the + /// root cause. + #[must_use] + pub fn format_error(message: &str, trace: &[String]) -> String { + if trace.is_empty() { + message.to_owned() + } else { + format!("{message}\n\nTrace:\n{}", trace.join("\n")) + } + } + + /// Convert command output to ordered content with a typed status. + /// + /// # Panics + /// + /// Panics on `NeedsInput`, which must be handled before final delivery. + pub fn into_tool_result(self, name: &str) -> ToolResult { + match self { + Self::Success(content) => ToolResult::text(content), + Self::TransientError { message, trace } => { + let mut result = + ToolResult::error(json!({"message": message, "trace": trace}).to_string()); + result.status = ToolStatus::Error(ErrorDetails { + transient: true, + trace, + }); + result + } + Self::FatalError { raw, trace } => { + let mut result = ToolResult::error(raw); + result.status = ToolStatus::Error(ErrorDetails { + transient: false, + trace, + }); + result + } + Self::Cancelled => ToolResult::text("Tool execution cancelled by user."), + Self::RawOutput { + stdout, + stderr, + success, + } => { + if success { + ToolResult::text(stdout) + } else { + ToolResult::error( + json!({ + "message": format!("Tool '{name}' execution failed."), + "stderr": stderr, + "stdout": stdout, + }) + .to_string(), + ) + } + } + Self::InvalidInquiry { question_id } => { + error!( + tool = name, + question_id = %question_id, + "tool produced an invalid inquiry: question id must be non-empty and must not \ + contain '.'" + ); + ToolResult::error( + "tool produced an invalid inquiry: question id must be non-empty and must not \ + contain '.'" + .to_owned(), + ) + } + Self::MalformedInquiry { detail } => { + error!( + tool = name, + %detail, + "tool produced a malformed inquiry that could not be parsed" + ); + ToolResult::error(format!( + "tool '{name}' produced a malformed inquiry that could not be parsed: {detail}" + )) + } + Self::NeedsInput(_) => { + unreachable!("NeedsInput should be handled by the caller") + } + } + } +} + +/// Receives a running tool's stderr lines as they arrive. +/// +/// Called from the forwarder's read loop, so it must not block: the loop has to +/// keep draining or the child fills its pipe and the tool call never completes. +/// A consumer that falls behind drops rather than stalls. +pub type StderrSink = Arc; + +/// Identity of a tool invocation, used to tag stderr lines forwarded to +/// tracing. +/// +/// Pass `None` to disable stderr forwarding (e.g. for argument-formatting +/// invocations where stderr is not meaningful to the user). +#[derive(Clone)] +pub struct ToolTrace<'a> { + pub id: &'a str, + pub name: &'a str, + + /// Where to send each line for display, in addition to tracing. + /// + /// `None` when nothing is watching, which is the common case: tracing and + /// the accumulated buffer are unaffected either way. + pub stderr: Option, +} + +impl fmt::Debug for ToolTrace<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ToolTrace") + .field("id", &self.id) + .field("name", &self.name) + .field("stderr", &self.stderr.is_some()) + .finish() + } +} + +/// Custom minijinja formatter used by [`run_tool_command`]. +/// +/// Scalars (strings, numbers, booleans) render raw — a template like +/// `{{tool.arguments.title}}` produces the bare string, not a JSON-quoted one. +/// Composites (sequences, maps, other iterables) serialize as JSON, so +/// `{{tool}}` and `{{context}}` produce valid JSON blobs without needing an +/// explicit `| tojson` filter at every call site. +/// `null`/undefined render as the literal `null`, matching the JSON convention +/// used by tool authors. +/// +/// Safe strings (e.g. the output of the `tojson` filter) pass through unchanged +/// so explicit opt-in JSON rendering continues to work. +fn format_tool_template_value( + out: &mut minijinja::Output<'_>, + _state: &minijinja::State<'_, '_>, + value: &minijinja::value::Value, +) -> Result<(), minijinja::Error> { + if value.is_safe() { + return write!(out, "{value}").map_err(Into::into); + } + + match value.kind() { + ValueKind::None | ValueKind::Undefined => write!(out, "null").map_err(Into::into), + ValueKind::String | ValueKind::Bool | ValueKind::Number => { + write!(out, "{value}").map_err(Into::into) + } + // Composites serialize as JSON so tool authors don't have to remember + // `| tojson` for every `{{tool}}` / `{{context}}` interpolation. + _ => { + let json = serde_json::to_string(value).map_err(|error| { + minijinja::Error::new( + MinijinjaErrorKind::BadSerialization, + "failed to serialize value as JSON", + ) + .with_source(error) + })?; + out.write_str(&json).map_err(Into::into) + } + } +} + +/// Run a tool command asynchronously with cancellation support. +/// +/// This is the **single entry point** for running tool commands (both execution +/// and argument formatting). +/// It handles: +/// +/// 1. Template rendering via [`minijinja`] +/// 2. Process spawning via Tokio's [`Command`] +/// 3. Cancellation via [`CancellationToken`] +/// 4. Parsing stdout as [`jp_tool::Outcome`] +/// 5. Forwarding the child's stderr to tracing (when `trace_as` is `Some`) +/// +/// # Panics +/// +/// Panics if tokio fails to attach the piped stdout/stderr handles to the +/// spawned child. +/// Both are requested via `Stdio::piped()`, so this is not expected to happen +/// in practice. +pub async fn run_tool_command( + command: CommandConfig, + ctx: Value, + root: &Utf8Path, + cancellation_token: CancellationToken, + trace_as: Option>, +) -> Result { + let CommandConfig { + program, + args, + shell, + } = command; + + let mut env = Environment::new(); + env.set_formatter(format_tool_template_value); + let tmpl = Arc::new(env); + + let program = tmpl + .render_str(&program, &ctx) + .map_err(|error| ToolError::TemplateError { + data: program.clone(), + error: Box::new(error), + })?; + + let args = args + .iter() + .map(|s| tmpl.render_str(s, &ctx)) + .collect::, _>>() + .map_err(|error| ToolError::TemplateError { + data: args.join(" "), + error: Box::new(error), + })?; + + let mut cmd = if shell { + // `program` is shell syntax and used verbatim; `args` are shell-quoted + // so multi-word arguments keep their boundaries. + let shell_cmd = shell_command_line(&program, &args); + + let mut cmd = Command::new("sh"); + cmd.arg("-c").arg(&shell_cmd); + cmd + } else { + let mut cmd = Command::new(&program); + cmd.args(&args); + cmd + }; + + // Isolate the child from JP's process group so terminal signals + // (Ctrl+C / SIGINT) don't kill it. JP manages tool lifecycle via + // the cancellation token, not Unix signals. + #[cfg(unix)] + cmd.process_group(0); + + // Ensure the child is killed when the tokio task is aborted on + // cancellation. Without this the process would be orphaned. + cmd.kill_on_drop(true); + + let mut child = cmd + .current_dir(root.as_std_path()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| ToolError::SpawnError { + command: format!( + "{} {}", + cmd.as_std().get_program().to_string_lossy(), + cmd.as_std() + .get_args() + .filter_map(OsStr::to_str) + .collect::>() + .join(" ") + ), + error, + })?; + + let stdout = child.stdout.take().expect("stdout piped"); + let stderr = child.stderr.take().expect("stderr piped"); + + let run = async { + tokio::try_join!( + read_all(stdout), + forward_stderr(stderr, trace_as), + child.wait(), + ) + }; + + tokio::select! { + biased; + () = cancellation_token.cancelled() => Ok(CommandResult::Cancelled), + result = run => Ok(match result { + Ok((stdout, stderr, status)) => { + parse_command_output(&stdout, &stderr, status.success()) + } + Err(error) => CommandResult::RawOutput { + stdout: String::new(), + stderr: error.to_string(), + success: false, + }, + }), + } +} + +/// Drain a child pipe into a byte buffer. +async fn read_all(mut pipe: impl tokio::io::AsyncRead + Unpin) -> std::io::Result> { + let mut buf = Vec::new(); + pipe.read_to_end(&mut buf).await?; + Ok(buf) +} + +/// Drain a child's stderr into a byte buffer, optionally forwarding each line +/// to tracing as it arrives. +/// +/// Uses byte-level line reading so non-UTF-8 stderr doesn't terminate the +/// forwarder. +async fn forward_stderr( + pipe: impl tokio::io::AsyncRead + Unpin, + trace_as: Option>, +) -> std::io::Result> { + let mut reader = BufReader::new(pipe); + let mut all = Vec::new(); + let mut line = Vec::new(); + + loop { + line.clear(); + if reader.read_until(b'\n', &mut line).await? == 0 { + break; + } + + if let Some(ToolTrace { id, name, stderr }) = &trace_as { + let text = String::from_utf8_lossy(&line); + let trimmed = text.trim_end_matches(['\n', '\r']); + if !trimmed.is_empty() { + trace!(target: "tool::stderr", tool_id = id, tool_name = name, "{trimmed}"); + + if let Some(sink) = stderr { + sink(trimmed); + } + } + } + + all.extend_from_slice(&line); + } + + Ok(all) +} + +/// Parse raw command output into a [`CommandResult`]. +/// +/// Tries to deserialize stdout as [`jp_tool::Outcome`]. +/// If that fails, falls back to [`CommandResult::RawOutput`]. +fn parse_command_output(stdout: &[u8], stderr: &[u8], success: bool) -> CommandResult { + let stdout_str = String::from_utf8_lossy(stdout); + + match serde_json::from_str::(&stdout_str) { + Ok(Outcome::Success { content }) => CommandResult::Success(content), + Ok(Outcome::Error { + transient, + message, + trace, + }) => { + if transient { + CommandResult::TransientError { message, trace } + } else { + CommandResult::FatalError { + raw: stdout_str.into_owned(), + trace, + } + } + } + Ok(Outcome::NeedsInput { question }) => CommandResult::NeedsInput(question), + // A payload shaped like a `needs_input` outcome that fails to + // deserialize must become a tool-level error, not `RawOutput`: + // silently handing the raw JSON to the model hides the failure (a + // stale local-tool binary emitting an older wire shape than this build + // parses, an invalid question id, a missing field) and leaves the + // model to invent an explanation. Output that is not an `Outcome` at + // all stays `RawOutput`. + Err(error) => { + if !Outcome::claims_needs_input(&stdout_str) { + return CommandResult::RawOutput { + stdout: stdout_str.into_owned(), + stderr: String::from_utf8_lossy(stderr).into_owned(), + success, + }; + } + + match Outcome::claimed_question_id(&stdout_str) { + // The id itself is the problem: empty, or containing the `.` + // reserved as the inquiry-id separator (`QuestionId` rejects + // both). + Some(id) if id.is_empty() || id.contains('.') => { + CommandResult::InvalidInquiry { question_id: id } + } + // Some other field failed to parse (wrong shape, missing + // field, protocol skew). + _ => CommandResult::MalformedInquiry { detail: error }, + } + } + } +} + +/// Everything an execution attempt needs, fixed for the life of one invocation. +/// +/// A tool that asks for input ends its attempt and is run again once the answer +/// arrives, so [`execute`] takes the accumulated answers separately: they are +/// the only thing that differs between one attempt and the next. +pub struct Execution<'a> { + /// The tool's advertised name and argument schema. + pub definition: &'a ToolDefinition, + + /// Correlation id echoed back on the outcome. + pub id: String, + + /// Arguments as the caller supplied them. + /// Each attempt coerces its own copy to the schema. + pub arguments: Value, + + /// Where the tool comes from, and how it is configured to run. + pub config: &'a ToolConfigWithDefaults, + + /// Working directory for a local command, and the root its access policy + /// resolves paths against. + pub root: &'a Utf8Path, + + /// Compiled access grants, or `None` for a tool that declares no policy. + pub access: Option<&'a AccessPolicy>, + + /// Workspace and conversation the call belongs to. + pub invocation: &'a InvocationContext, + + /// Rust implementations, reached by a `builtin` source. + pub builtins: &'a builtin::BuiltinExecutors, + + /// Upstream connections, reached by an `mcp` source. + pub upstream: &'a Client, + + /// Stops the attempt in progress. + pub cancellation: CancellationToken, + + /// Receives the tool's stderr lines as they arrive, for a caller showing + /// progress. + /// `None` when nothing is watching; the lines still reach tracing either + /// way. + pub stderr: Option, +} + +impl Execution<'_> { + /// The name the tool's own implementation answers to, which is the + /// configured `source` name when it differs from the advertised one. + fn invoked_name<'n>(&'n self, source_name: Option<&'n str>) -> &'n str { + source_name.unwrap_or(&self.definition.name) + } + + /// Build the trusted template and metadata context for one attempt. + fn context(&self, name: &str, arguments: &Value, answers: &Answers, action: &Action) -> Value { + tool_context( + name, + arguments, + answers, + self.config, + self.root, + action, + self.access, + self.invocation, + ) + } +} + +/// Answers a tool's earlier questions received, keyed by question id. +pub type Answers = IndexMap; + +/// Run one execution attempt, without any interactive prompt. +/// +/// Every interactive decision — admission, argument editing, who answers a +/// question, result review — belongs to the caller. +/// This resolves the tool's source, runs it once, and reports what came back. +/// +/// An [`ExecutionOutcome::NeedsInput`] outcome ends the attempt. +/// Call again with the answer added to `answers` to run the tool a second time; +/// it is not suspended and resumed. +/// +/// # Errors +/// +/// Returns [`ToolError`] when the tool could not be run at all: a missing +/// command, a spawn failure, an unreachable MCP server, a malformed result +/// envelope. +/// A tool that ran and reported its own failure is an +/// [`ExecutionOutcome::Completed`] carrying an error [`ToolResult`], not an +/// `Err`. +pub async fn execute( + execution: &Execution<'_>, + answers: &Answers, +) -> Result { + let mut arguments = execution.arguments.clone(); + if let Some(object) = arguments.as_object_mut() { + execution.definition.coerce_arguments(object); + } + info!(tool = %execution.definition.name, arguments = ?arguments, "Executing tool."); + + match execution.config.source() { + ToolSource::Local { tool } => { + execute_local(execution, arguments, answers, tool.as_deref()).await + } + ToolSource::Mcp { server, tool } => { + execute_mcp(execution, arguments, answers, server, tool.as_deref()).await + } + ToolSource::Builtin { tool } => { + execute_builtin(execution, &arguments, answers, tool.as_deref()).await + } + } +} + +/// Execute a local tool and return the outcome. +/// +/// Runs one local command attempt. +/// It validates arguments, runs the command, and converts the result to an +/// `ExecutionOutcome`. +async fn execute_local( + execution: &Execution<'_>, + mut arguments: Value, + answers: &Answers, + tool: Option<&str>, +) -> Result { + let name = execution.invoked_name(tool); + let id = execution.id.clone(); + + // Apply configured defaults for missing parameters, then validate. + if let Some(args) = arguments.as_object_mut() { + apply_parameter_defaults(args, &execution.definition.parameters); + + if let Err(error) = validate_tool_arguments(args, &execution.definition.parameters) { + return Ok(ExecutionOutcome::Completed { + id, + result: ToolResult::error(format!( + "Invalid arguments: {error}\n\nYou can call `describe_tools(tools: \ + [\"{name}\"])` to learn more about how to use the tool correctly." + )), + }); + } + } + + let ctx = execution.context(name, &arguments, answers, &Action::Run); + + let Some(command) = execution.config.command() else { + return Err(ToolError::MissingCommand); + }; + + let trace_as = ToolTrace { + id: &id, + name, + stderr: execution.stderr.clone(), + }; + + let outcome = run_tool_command( + command, + ctx, + execution.root, + execution.cancellation.clone(), + Some(trace_as), + ) + .await?; + + match outcome { + CommandResult::Success(content) => Ok(ExecutionOutcome::Completed { + id, + result: ToolResult::text(content), + }), + CommandResult::NeedsInput(question) => Ok(ExecutionOutcome::NeedsInput { id, question }), + CommandResult::Cancelled => Ok(ExecutionOutcome::Cancelled { id }), + other => Ok(ExecutionOutcome::Completed { + id, + result: other.into_tool_result(name), + }), + } +} + +/// Execute an MCP tool and return the outcome. +/// +/// Runs one upstream MCP call. +/// It calls the MCP server and converts the result to an `ExecutionOutcome`. +async fn execute_mcp( + execution: &Execution<'_>, + arguments: Value, + answers: &Answers, + server: &str, + tool: Option<&str>, +) -> Result { + let name = execution.invoked_name(tool); + let id = execution.id.clone(); + + let context = execution.context(name, &arguments, answers, &Action::Run); + let meta = Map::from_iter([ + ("computer.jp/tool".into(), context["tool"].clone()), + ("computer.jp/context".into(), context["context"].clone()), + ]); + let call_future = execution + .upstream + .call_tool(name, server, &arguments, Some(meta)); + + let response = tokio::select! { + biased; + () = execution.cancellation.cancelled() => { + info!(tool = %execution.definition.name, "MCP tool call cancelled"); + return Ok(ExecutionOutcome::Cancelled { id }); + } + result = call_future => result.map_err(|error| ToolError::McpRunToolError(Box::new(error)))?, + }; + + let result = match decode_result(response).map_err(ToolError::MalformedOutput)? { + UpstreamResult::Native(response) => { + from_mcp(response).map_err(ToolError::MalformedOutput)? + } + UpstreamResult::Outcome { outcome, response } => match outcome { + Outcome::NeedsInput { question } => { + return Ok(ExecutionOutcome::NeedsInput { id, question }); + } + Outcome::Success { content } => from_mcp(replace_envelope(response, &content, false)) + .map_err(ToolError::MalformedOutput)?, + Outcome::Error { + message, + trace, + transient, + } => { + let text = if transient { + json!({"message":message, "trace":trace}).to_string() + } else { + from_mcp(response.clone()) + .map_err(ToolError::MalformedOutput)? + .to_text() + }; + let mut result = from_mcp(replace_envelope(response, &text, true)) + .map_err(ToolError::MalformedOutput)?; + result.status = ToolStatus::Error(ErrorDetails { transient, trace }); + result + } + }, + }; + Ok(ExecutionOutcome::Completed { id, result }) +} + +/// Execute a builtin tool and return the outcome. +/// +/// `source_name` is the implementation named by `source = "builtin."`, +/// which the registry is keyed on. +/// When absent, the implementation shares the tool's own name. +async fn execute_builtin( + execution: &Execution<'_>, + arguments: &Value, + answers: &Answers, + source_name: Option<&str>, +) -> Result { + let name = execution.invoked_name(source_name); + let id = execution.id.clone(); + let executor = execution + .builtins + .get(name) + .ok_or_else(|| ToolError::NotFound { + name: name.to_owned(), + })?; + + let outcome = executor.execute(arguments, answers).await; + + Ok(match outcome { + Outcome::Success { content } => ExecutionOutcome::Completed { + id, + result: ToolResult::text(content), + }, + outcome @ Outcome::Error { .. } => ExecutionOutcome::Completed { + id, + result: outcome.into(), + }, + Outcome::NeedsInput { question } => ExecutionOutcome::NeedsInput { id, question }, + }) +} + +/// Resolve all enabled tool definitions from config. +/// +/// If `forced_tool` is provided (e.g. from `ToolChoice::Function`), that tool +/// is included even when it is disabled, preventing a mismatch between +/// `tool_choice` and the declared tools list that some providers (notably +/// Google/Gemini) reject outright. +/// +/// A locked-off tool (`state = false`, `allow_toggle = never`) is the +/// exception: it is always dropped, even when named by `forced_tool`. +pub async fn tool_definitions( + configs: impl Iterator, + mcp_client: &Client, + forced_tool: Option<&str>, +) -> Result, ToolError> { + let mut definitions = Vec::new(); + + for (name, config) in configs { + let enable = config.effective_enable(); + let forced = forced_tool.is_some_and(|f| f == name); + // Drop disabled tools, but keep a forced tool unless it is locked-off. + if !enable.is_enabled() && (!forced || enable.is_locked()) { + continue; + } + + // Drop MCP-backed tools whose server failed to start while marked + // optional. The server is absent from the running services map, and + // we don't want to hand the LLM a tool it cannot invoke. + if let ToolSource::Mcp { server, .. } = config.source() { + let server_id = McpServerId::new(server); + if !mcp_client.is_running(&server_id).await { + warn!( + tool = name, + server = %server, + "Skipping MCP tool: backing server is not running." + ); + continue; + } + } + + // A tool JP cannot describe to the provider is dropped rather than + // failing the query, matching the unavailable-server case above. A tool + // the caller named explicitly is the exception: silently omitting it + // would leave `tool_choice` pointing at a tool the provider never saw. + let definition = match resolve_tool(name, &config, mcp_client).await { + Ok(definition) => definition, + Err(error) if !forced => { + warn!( + tool = name, + %error, + "Skipping tool: its parameter schema could not be resolved." + ); + continue; + } + Err(error) => return Err(error), + }; + definitions.push(definition); + } + + Ok(definitions) +} + +/// Resolve a single tool definition and its documentation. +async fn resolve_tool( + name: &str, + config: &ToolConfigWithDefaults, + mcp_client: &Client, +) -> Result { + let path = format!("conversation.tools.{name}.parameters"); + let definition = match config.source() { + ToolSource::Local { .. } | ToolSource::Builtin { .. } => ToolDefinition { + name: name.to_owned(), + docs: tool_docs_from_config(config), + parameters: json_schema::from_config(&path, config.parameters())?, + }, + ToolSource::Mcp { server, tool } => { + resolve_mcp_tool(server, name, tool.as_deref(), config, mcp_client).await? + } + }; + + jp_tool::schema::validate(&path, &definition.parameters)?; + + Ok(definition) +} + +/// Resolve an MCP tool: fetch from server, merge config overrides, auto-split +/// descriptions into summary + detail. +async fn resolve_mcp_tool( + server: &str, + name: &str, + source_name: Option<&str>, + config: &ToolConfigWithDefaults, + mcp_client: &Client, +) -> Result { + let mcp_tool = { + trace!(server = %server, tool = %name, "Fetching tool from MCP server"); + + let server_id = McpServerId::new(server); + mcp_client + .get_tool(&McpToolId::new(source_name.unwrap_or(name)), &server_id) + .await + .map_err(|error| ToolError::McpGetToolError(Box::new(error))) + }?; + + let user_overrides = config.parameters(); + + // Merge tool-level description. + let merged_description = merge_description( + config.description().map(str::to_owned), + mcp_tool.description.as_deref(), + ); + + // The server's document is the source of truth; configuration may narrow + // it, and nothing else touches it. + let source = Value::Object(mcp_tool.input_schema.as_ref().clone()); + let parameters = json_schema::with_overrides( + &format!("conversation.tools.{name}.parameters"), + &source, + user_overrides, + )?; + + // Build docs with auto-split heuristic. + let has_user_summary = config.summary().is_some(); + + let (summary, description) = if has_user_summary { + // User provided explicit summary -- use config fields as-is. + ( + config.summary().map(str::to_owned), + config.description().map(str::to_owned), + ) + } else if let Some(ref desc) = merged_description { + let (s, d) = split_description(desc); + (Some(s), d) + } else { + (None, None) + }; + + let examples = config.examples().map(str::to_owned); + + // Per-parameter docs: auto-split MCP descriptions when user didn't override. + let param_docs = Node::root(¶meters) + .properties() + .into_iter() + .filter_map(|(pname, pnode)| { + let user_override = user_overrides.get(&pname); + let has_user_param_summary = user_override.and_then(|o| o.summary.as_ref()).is_some(); + + let (summary, desc) = if has_user_param_summary { + let summary = user_override + .and_then(|o| o.summary.as_deref()) + .or(user_override.and_then(|o| o.description.as_deref())) + .map(str::to_owned); + let desc = user_override + .and_then(|o| o.description.as_deref()) + .map(str::to_owned); + (summary, desc) + } else if let Some(resolved) = pnode.description() { + let (s, d) = split_description(resolved); + (Some(s), d) + } else { + (None, None) + }; + + let ex = user_override + .and_then(|o| o.examples.as_deref()) + .map(str::to_owned); + + if summary.is_none() && desc.is_none() && ex.is_none() { + return None; + } + + Some((pname, ParameterDocs { + summary, + description: desc, + examples: ex, + })) + }) + .collect(); + + let docs = ToolDocs { + summary, + description, + examples, + parameters: param_docs, + }; + + Ok(ToolDefinition { + name: name.to_owned(), + docs, + parameters, + }) +} + +#[cfg(test)] +#[path = "server_tests.rs"] +mod tests; diff --git a/crates/jp_llm/src/tool/builtin.rs b/crates/jp_mcp/src/server/builtin.rs similarity index 66% rename from crates/jp_llm/src/tool/builtin.rs rename to crates/jp_mcp/src/server/builtin.rs index 538e8a28b..e0d27bd5e 100644 --- a/crates/jp_llm/src/tool/builtin.rs +++ b/crates/jp_mcp/src/server/builtin.rs @@ -8,26 +8,25 @@ use std::{collections::HashMap, sync::Arc}; use async_trait::async_trait; use indexmap::IndexMap; -use jp_conversation::event::InquirySource; use jp_tool::Outcome; use serde_json::Value; /// A built-in tool that executes Rust code instead of shelling out. +/// +/// The return type is [`Outcome`], so a built-in produces text, an error, or a +/// question — the same three shapes a local command can print on stdout. +/// A resource, an image, structured content, or MCP annotations are not +/// reachable from here; only a tool on an upstream MCP server can return those, +/// because only that path carries a native MCP result into [`ToolResult`]. +/// Widening this is [RFD 058]'s work, not something to route around one tool at +/// a time. +/// +/// [RFD 058]: https://jp.computer/rfd/058-typed-content-blocks-for-tool-responses +/// [`ToolResult`]: jp_tool::ToolResult #[async_trait] pub trait BuiltinTool: Send + Sync { /// Execute the tool with the given arguments and accumulated answers. async fn execute(&self, arguments: &Value, answers: &IndexMap) -> Outcome; - - /// The persisted `InquirySource` for questions emitted by this tool. - /// - /// Default: `InquirySource::Tool { name }`. - /// Override for tools whose questions are semantically the assistant's, not - /// the tool's (e.g. `ask_user`). - fn inquiry_source(&self, name: &str) -> InquirySource { - InquirySource::Tool { - name: name.to_owned(), - } - } } /// Registry mapping builtin tool names to their executors. diff --git a/crates/jp_llm/src/tool/builtin/describe_tools.rs b/crates/jp_mcp/src/server/builtin/describe_tools.rs similarity index 98% rename from crates/jp_llm/src/tool/builtin/describe_tools.rs rename to crates/jp_mcp/src/server/builtin/describe_tools.rs index 2d4ab9071..9c55fd631 100644 --- a/crates/jp_llm/src/tool/builtin/describe_tools.rs +++ b/crates/jp_mcp/src/server/builtin/describe_tools.rs @@ -2,10 +2,10 @@ use async_trait::async_trait; use indexmap::IndexMap; -use jp_tool::Outcome; +use jp_tool::{Outcome, ToolDocs}; use serde_json::Value; -use crate::tool::{BuiltinTool, ToolDocs}; +use crate::server::BuiltinTool; pub struct DescribeTools { docs: IndexMap, diff --git a/crates/jp_llm/src/tool/builtin/describe_tools_tests.rs b/crates/jp_mcp/src/server/builtin/describe_tools_tests.rs similarity index 99% rename from crates/jp_llm/src/tool/builtin/describe_tools_tests.rs rename to crates/jp_mcp/src/server/builtin/describe_tools_tests.rs index e250ef257..5dda78db6 100644 --- a/crates/jp_llm/src/tool/builtin/describe_tools_tests.rs +++ b/crates/jp_mcp/src/server/builtin/describe_tools_tests.rs @@ -1,9 +1,8 @@ use indexmap::IndexMap; -use jp_tool::Outcome; +use jp_tool::{Outcome, ParameterDocs, ToolDocs}; use serde_json::{Value, json}; use super::*; -use crate::tool::{ParameterDocs, ToolDocs}; fn empty_tool_docs() -> ToolDocs { ToolDocs { diff --git a/crates/jp_mcp/src/server/conformance_tests.rs b/crates/jp_mcp/src/server/conformance_tests.rs new file mode 100644 index 000000000..96cd32578 --- /dev/null +++ b/crates/jp_mcp/src/server/conformance_tests.rs @@ -0,0 +1,1130 @@ +//! Independent HTTP client fixtures for the MCP Host/third-party boundary. + +#[cfg(unix)] +use std::fs; +use std::{ + io, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, +}; + +use async_trait::async_trait; +use camino_tempfile::{Utf8TempDir, tempdir}; +use indexmap::IndexMap; +use jp_config::{ + AppConfig, Config as _, + conversation::tool::{PartialToolConfig, ToolConfig}, +}; +use jp_tool::{Outcome, Question, ToolResult}; +use reqwest::{Client as HttpClient, Response, redirect::Policy}; +use rmcp::model::{CallToolRequestParams, Meta}; +use serde_json::{Map, Value, json}; +use tokio::{ + sync::mpsc::error::TryRecvError, + time::{Duration, timeout}, +}; + +use super::Endpoint; +use crate::{ + Client, Content, + server::{ + InvocationContext, + builtin::{BuiltinExecutors, BuiltinTool}, + service::{ + Admission, ConfiguredTool, HostError, HostReceiver, HostRequest, InputAnswer, + Interaction, ReleaseDecision, Service, + }, + tool_definitions, + }, +}; + +const PROTOCOL_VERSION: &str = "2025-11-25"; + +#[derive(Clone)] +struct ExternalClient { + http: HttpClient, + url: String, + session: String, +} + +impl ExternalClient { + async fn connect(url: &str) -> Self { + let http = HttpClient::builder() + .no_proxy() + .redirect(Policy::none()) + .timeout(Duration::from_secs(10)) + .build() + .unwrap(); + let response = http.post(url).header("accept", "application/json, text/event-stream").json(&json!({ + "jsonrpc":"2.0", "id":0, "method":"initialize", + "params":{"protocolVersion":PROTOCOL_VERSION,"capabilities":{},"clientInfo":{"name":"third-party-fixture","version":"1"}} + })).send().await.unwrap(); + assert_eq!(response.status().as_u16(), 200); + let session = response + .headers() + .get("mcp-session-id") + .unwrap() + .to_str() + .unwrap() + .to_owned(); + let initialized = SseReader::new(response).reply(0).await; + assert_eq!(initialized["result"]["protocolVersion"], PROTOCOL_VERSION); + let client = Self { + http, + url: url.into(), + session, + }; + client.notify("notifications/initialized", json!({})).await; + client + } + + async fn request(&self, id: u64, method: &str, params: Value) -> SseReader { + let response = self + .http + .post(&self.url) + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", &self.session) + .header("mcp-protocol-version", PROTOCOL_VERSION) + .json(&json!({"jsonrpc":"2.0","id":id,"method":method,"params":params})) + .send() + .await + .unwrap(); + assert_eq!(response.status().as_u16(), 200); + SseReader::new(response) + } + + async fn notify(&self, method: &str, params: Value) { + let response = self + .http + .post(&self.url) + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", &self.session) + .header("mcp-protocol-version", PROTOCOL_VERSION) + .json(&json!({"jsonrpc":"2.0","method":method,"params":params})) + .send() + .await + .unwrap(); + assert_eq!(response.status().as_u16(), 202); + assert_eq!(response.bytes().await.unwrap().as_ref(), b""); + } + + async fn resume(&self, event_id: &str) -> SseReader { + let response = self + .http + .get(&self.url) + .header("accept", "text/event-stream") + .header("mcp-session-id", &self.session) + .header("mcp-protocol-version", PROTOCOL_VERSION) + .header("last-event-id", event_id) + .send() + .await + .unwrap(); + assert_eq!(response.status().as_u16(), 200); + SseReader::new(response) + } + + async fn close(self) { + let response = self + .http + .delete(&self.url) + .header("mcp-session-id", &self.session) + .header("mcp-protocol-version", PROTOCOL_VERSION) + .send() + .await + .unwrap(); + assert_eq!(response.status().as_u16(), 202); + } +} + +struct SseReader { + response: Response, + buffer: Vec, +} + +impl SseReader { + fn new(response: Response) -> Self { + Self { + response, + buffer: Vec::new(), + } + } + + /// Read one complete event, including a priming event carrying no data. + /// + /// Decoding after finding the delimiter rather than before handles a UTF-8 + /// code point split across two chunks. + async fn frame(&mut self) -> (Option, Option) { + loop { + if let Some((start, len)) = Self::delimiter(&self.buffer) { + let bytes = self.buffer.drain(..start + len).collect::>(); + let frame = String::from_utf8(bytes).unwrap(); + let id = frame + .lines() + .find_map(|line| line.strip_prefix("id:")) + .map(|value| value.trim().to_owned()); + let data = frame + .lines() + .filter_map(|line| line.strip_prefix("data:")) + .map(str::trim_start) + .collect::>() + .join("\n"); + return ( + id, + (!data.is_empty()).then(|| serde_json::from_str(&data).unwrap()), + ); + } + let chunk = self + .response + .chunk() + .await + .unwrap() + .expect("SSE ended before the response"); + self.buffer.extend_from_slice(&chunk); + } + } + + /// Where the first event delimiter starts, and how long it is. + /// + /// The SSE grammar ends an event on a blank line, whose line break may be + /// LF, CRLF, or a bare CR. + /// A reader that only knows `\n\n` would hang on a conforming server rather + /// than report what it received. + fn delimiter(buffer: &[u8]) -> Option<(usize, usize)> { + let crlf = buffer.windows(4).position(|bytes| bytes == b"\r\n\r\n"); + let lf = buffer.windows(2).position(|bytes| bytes == b"\n\n"); + let cr = buffer.windows(2).position(|bytes| bytes == b"\r\r"); + // The earliest match wins, and a CRLF pair starting at the same offset + // as a bare LF would have matched one byte later. + [(crlf, 4), (lf, 2), (cr, 2)] + .into_iter() + .filter_map(|(start, len)| Some((start?, len))) + .min_by_key(|(start, len)| (*start, std::cmp::Reverse(*len))) + } + + async fn reply(mut self, id: u64) -> Value { + loop { + if let (_, Some(message)) = self.frame().await { + assert_eq!(message["jsonrpc"], "2.0"); + assert_eq!(message["id"], id); + return message; + } + } + } + + /// Read up to the next notification of `method`, returning its parameters. + /// + /// A reply arriving first means the notification was never sent, so it + /// fails here rather than leaving the caller to wait out its own timeout. + async fn notification(&mut self, method: &str) -> Value { + loop { + let (_, Some(message)) = self.frame().await else { + continue; + }; + assert_eq!(message["jsonrpc"], "2.0"); + assert!( + message["id"].is_null(), + "expected a {method} notification, got the reply: {message}" + ); + if message["method"] == method { + return message["params"].clone(); + } + } + } + + /// Read the reply with `id`, discarding notifications sent ahead of it. + async fn reply_after_notifications(mut self, id: u64) -> Value { + loop { + let (_, Some(message)) = self.frame().await else { + continue; + }; + assert_eq!(message["jsonrpc"], "2.0"); + if !message["id"].is_null() { + assert_eq!(message["id"], id); + return message; + } + } + } +} + +/// The fixture client has to frame events the way a conforming server may send +/// them, or a future transport change looks like a hang rather than a failure. +#[test] +fn the_sse_reader_frames_every_line_break_the_grammar_allows() { + assert_eq!(SseReader::delimiter(b"data: x\n\nrest"), Some((7, 2))); + assert_eq!(SseReader::delimiter(b"data: x\r\n\r\nrest"), Some((7, 4))); + assert_eq!(SseReader::delimiter(b"data: x\r\rrest"), Some((7, 2))); + assert_eq!(SseReader::delimiter(b"data: x\n"), None); + + // A CRLF pair must be consumed whole: taking the inner `\n\n` would leave + // a stray `\r` at the head of the next event. + assert_eq!(SseReader::delimiter(b"a\r\n\r\nb\n\nc"), Some((1, 4))); +} + +struct Fixture { + endpoint: Endpoint, + host: HostReceiver, + root: Utf8TempDir, +} + +impl Fixture { + async fn shutdown(self) { + self.endpoint.shutdown().await.unwrap(); + // The working directory must outlive service cleanup. + drop(self.root); + } +} + +async fn fixture(config: Value, builtins: BuiltinExecutors) -> Fixture { + fixture_reporting_every(config, builtins, super::PROGRESS_HEARTBEAT).await +} + +async fn fixture_reporting_every( + config: Value, + builtins: BuiltinExecutors, + heartbeat: Duration, +) -> Fixture { + let root = tempdir().unwrap(); + let mut cfg = AppConfig::new_test(); + let config: PartialToolConfig = serde_json::from_value(config).unwrap(); + cfg.conversation.tools.insert( + "probe".into(), + ToolConfig::from_partial(config, vec![]).unwrap(), + ); + let upstream = Client::default(); + let definitions = tool_definitions(cfg.conversation.tools.iter(), &upstream, None) + .await + .unwrap(); + let tools = definitions + .into_iter() + .map(|definition| ConfiguredTool { + config: cfg.conversation.tools.get(&definition.name).unwrap(), + definition, + access: Ok(None), + metadata: + json!({"anthropic/maxResultSizeChars":500_000,"fixture/hint":{"opaque":true}}) + .as_object() + .unwrap() + .clone(), + }) + .collect(); + let (service, host) = Service::new( + tools, + upstream, + builtins, + root.path().to_owned(), + InvocationContext { + workspace_id: "workspace-1".into(), + conversation_id: "conversation-1".into(), + }, + ) + .unwrap(); + Fixture { + endpoint: Endpoint::start_reporting_every(service, heartbeat) + .await + .unwrap(), + host, + root, + } +} + +struct Ordinal(Arc); +#[async_trait] +impl BuiltinTool for Ordinal { + async fn execute(&self, _: &Value, _: &IndexMap) -> Outcome { + format!("execution-{}", self.0.fetch_add(1, Ordering::SeqCst) + 1).into() + } +} + +async fn counting_fixture() -> (Fixture, Arc) { + let count = Arc::new(AtomicUsize::new(0)); + let fixture = fixture( + json!({"source":"builtin", "summary":"Probe", "run":"ask", "result":"edit"}), + BuiltinExecutors::new().register("probe", Ordinal(count.clone())), + ) + .await; + (fixture, count) +} + +async fn next(host: &mut HostReceiver) -> HostRequest { + timeout(Duration::from_secs(5), host.recv()) + .await + .unwrap() + .expect("Host channel closed") +} + +/// A fixture whose `probe` tool requires one integer argument. +async fn integer_argument_fixture(count: &Arc) -> Fixture { + fixture( + json!({ + "source": "builtin", + "run": "ask", + "parameters": {"value": {"type": "integer", "required": true}}, + }), + BuiltinExecutors::new().register("probe", Ordinal(count.clone())), + ) + .await +} + +#[tokio::test] +async fn external_discovery_preserves_host_metadata_without_executing() { + let (mut fixture, count) = counting_fixture().await; + let client = ExternalClient::connect(fixture.endpoint.url()).await; + let result = client + .request(1, "tools/list", json!({})) + .await + .reply(1) + .await; + assert_eq!( + result, + json!({"jsonrpc":"2.0","id":1,"result":{"tools":[{ + "name":"probe","description":"Probe","inputSchema":{"type":"object","properties":{},"required":[]}, + "_meta":{"anthropic/maxResultSizeChars":500_000,"fixture/hint":{"opaque":true}} + }]}}) + ); + assert_eq!(count.load(Ordering::SeqCst), 0); + assert!(matches!(fixture.host.try_recv(), Err(TryRecvError::Empty))); + client.close().await; + fixture.shutdown().await; +} + +/// A caller that supplies a progress token is told what the tool writes to +/// stderr, line by line, while the call is still running. +/// A caller given nothing to go on cannot tell a working tool from a stuck one, +/// and clients abandon calls they have heard nothing about. +#[tokio::test] +#[cfg(unix)] +async fn external_progress_token_receives_each_stderr_line_before_the_result() { + let mut fixture = fixture( + json!({ + "source": "local", + "command": {"program": "sh", "shell": false, "args": [ + "-c", "printf 'step one\\nstep two\\n' >&2; printf '%s' 'finished'", + ]}, + }), + BuiltinExecutors::new(), + ) + .await; + let client = ExternalClient::connect(fixture.endpoint.url()).await; + let mut response = client + .request( + 7, + "tools/call", + json!({"name":"probe","arguments":{},"_meta":{"progressToken":"probe-7"}}), + ) + .await; + let Interaction::Prepare { reply, .. } = next(&mut fixture.host).await.interaction else { + panic!("expected preparation") + }; + reply + .send(Ok(Admission::Run { + arguments: Map::new(), + })) + .unwrap(); + let Interaction::Release { reply, .. } = next(&mut fixture.host).await.interaction else { + panic!("expected release") + }; + reply.send(Ok(ReleaseDecision::Execute)).unwrap(); + + // Read before answering the recording barrier: the point of these + // notifications is that they reach the caller while the call is in flight, + // and the barrier is what holds the result back until they have. + for (count, line) in [(1.0, "step one"), (2.0, "step two")] { + let params = timeout( + Duration::from_secs(5), + response.notification("notifications/progress"), + ) + .await + .expect("a running call must report the output of the tool it started"); + assert_eq!( + params, + json!({"progressToken":"probe-7", "progress":count, "message":line}) + ); + } + + let Interaction::Record { reply, .. } = next(&mut fixture.host).await.interaction else { + panic!("expected record barrier") + }; + reply.send(Ok(())).unwrap(); + let result = response.reply_after_notifications(7).await; + assert_eq!( + result, + json!({"jsonrpc":"2.0","id":7,"result":{"content":[{"type":"text","text":"finished"}],"isError":false}}) + ); + client.close().await; + fixture.shutdown().await; +} + +/// A call reports that it is still alive even when nothing is happening, so +/// that the stretch spent waiting on the MCP Host for approval does not read as +/// a tool that has died. +#[tokio::test] +async fn external_progress_token_receives_liveness_while_the_call_waits() { + let (count, heartbeat) = (Arc::new(AtomicUsize::new(0)), Duration::from_millis(40)); + let mut fixture = fixture_reporting_every( + json!({"source":"builtin", "summary":"Probe"}), + BuiltinExecutors::new().register("probe", Ordinal(count.clone())), + heartbeat, + ) + .await; + let client = ExternalClient::connect(fixture.endpoint.url()).await; + let mut response = client + .request( + 9, + "tools/call", + json!({"name":"probe","arguments":{},"_meta":{"progressToken":"probe-9"}}), + ) + .await; + let pending = next(&mut fixture.host).await; + + // Deliberately left unanswered: the call is now waiting on the Host, the + // tool has not run, and there is nothing but the heartbeat to report. + for expected in [1.0, 2.0] { + let params = timeout( + Duration::from_secs(5), + response.notification("notifications/progress"), + ) + .await + .expect("a call waiting on the Host must still report that it is alive"); + assert_eq!(params["progressToken"], "probe-9"); + assert_eq!(params["progress"], json!(expected)); + // How long the fixture took to get here decides the seconds in the text, + // so only its shape is pinned. + let message = params["message"].as_str().unwrap(); + assert!( + message.starts_with("running for ") && message.ends_with('s'), + "{message}" + ); + } + assert_eq!(count.load(Ordering::SeqCst), 0); + + let Interaction::Prepare { reply, .. } = pending.interaction else { + panic!("expected preparation") + }; + reply + .send(Ok(Admission::Skip { + reason: "not today".into(), + })) + .unwrap(); + let Interaction::Record { reply, .. } = next(&mut fixture.host).await.interaction else { + panic!("expected record barrier") + }; + reply.send(Ok(())).unwrap(); + let result = response.reply_after_notifications(9).await; + assert_eq!( + result, + json!({"jsonrpc":"2.0","id":9,"result":{"content":[{"type":"text","text":"not today"}],"isError":false}}) + ); + assert_eq!(count.load(Ordering::SeqCst), 0); + client.close().await; + fixture.shutdown().await; +} + +#[tokio::test] +#[cfg(unix)] +#[expect( + clippy::too_many_lines, + reason = "Keep the inquiry, re-execution, and recording assertions in one linear scenario" +)] +async fn external_inquiry_reexecutes_with_host_answers_and_records_edited_output() { + let mut fixture = fixture(json!({ + "source":"local", "run":"ask", "result":"edit", "options":{"marker":"configured"}, + "parameters":{"value":{"type":"string","required":true}}, + "command":{"program":"sh","shell":false,"args":["-c", + "printf 'attempt\\n' >> attempts; if [ \"$1\" = null ]; then printf '%s' '{\"type\":\"needs_input\",\"question\":{\"id\":\"confirm\",\"text\":\"Continue?\",\"answer_type\":{\"type\":\"boolean\"}}}'; else printf '%s' \"$2\"; fi", + "fixture", "{{tool.answers.confirm}}", + "{{ {'value':tool.arguments.value,'answer':tool.answers.confirm,'action':context.action,'workspace':context.workspace_id,'conversation':context.conversation_id,'marker':tool.options.marker} | tojson }}" + ]} + }), BuiltinExecutors::new()).await; + let attacker = fixture.root.path().join("attacker"); + fs::create_dir(&attacker).unwrap(); + let client = ExternalClient::connect(fixture.endpoint.url()).await; + let meta = json!({ + "claudecode/toolUseId":"external-1", + "computer.jp/context":{"root":attacker,"workspace_id":"forged","conversation_id":"forged","action":"format_arguments"}, + "computer.jp/tool":{"answers":{"confirm":true},"options":{"marker":"forged"}}, + "anthropic/maxResultSizeChars":0 + }); + let response = client + .request( + 11, + "tools/call", + json!({"name":"probe","arguments":{"value":"requested"},"_meta":meta}), + ) + .await; + let pending = next(&mut fixture.host).await; + let id = pending.call.id; + assert_eq!(Value::Object(pending.call.request.correlation), meta); + let Interaction::Prepare { + arguments, reply, .. + } = pending.interaction + else { + panic!("expected preparation") + }; + assert_eq!( + arguments, + json!({"value":"requested"}).as_object().unwrap().clone() + ); + assert!(!fixture.root.path().join("attempts").exists()); + reply + .send(Ok(Admission::Run { + arguments: json!({"value":"edited"}).as_object().unwrap().clone(), + })) + .unwrap(); + let pending = next(&mut fixture.host).await; + assert_eq!(pending.call.id, id); + let Interaction::Release { reply, .. } = pending.interaction else { + panic!("expected release") + }; + reply.send(Ok(ReleaseDecision::Execute)).unwrap(); + let pending = next(&mut fixture.host).await; + assert_eq!(pending.call.id, id); + let Interaction::Input { + request, + answers, + reply, + .. + } = pending.interaction + else { + panic!("caller metadata must not answer the inquiry") + }; + assert_eq!(request.id.as_str(), "confirm"); + assert_eq!( + request.schema(), + json!({"type":"boolean"}).as_object().unwrap().clone() + ); + assert!(answers.is_empty()); + assert_eq!( + fs::read_to_string(fixture.root.path().join("attempts")).unwrap(), + "attempt\n" + ); + reply.send(Ok(InputAnswer::Answer(json!(false)))).unwrap(); + let pending = next(&mut fixture.host).await; + assert_eq!(pending.call.id, id); + let Interaction::Review { result, reply, .. } = pending.interaction else { + panic!("expected review") + }; + assert!(!result.is_error()); + let raw = result.to_text(); + assert_eq!( + serde_json::from_str::(&raw).unwrap(), + json!({"value":"edited","answer":false,"action":"run","workspace":"workspace-1","conversation":"conversation-1","marker":"configured"}) + ); + assert_eq!( + fs::read_to_string(fixture.root.path().join("attempts")).unwrap(), + "attempt\nattempt\n" + ); + assert!(!attacker.join("attempts").exists()); + reply.send(Ok(ToolResult::text("approved output"))).unwrap(); + let pending = next(&mut fixture.host).await; + assert_eq!(pending.call.id, id); + assert_eq!( + pending.call.request.arguments, + json!({"value":"requested"}).as_object().unwrap().clone() + ); + let Interaction::Record { recording, reply } = pending.interaction else { + panic!("expected record barrier") + }; + assert_eq!( + recording.arguments, + json!({"value":"edited"}).as_object().unwrap().clone() + ); + assert_eq!(recording.raw_result, Some(ToolResult::text(raw))); + assert_eq!(recording.result, ToolResult::text("approved output")); + let mut returned = tokio::spawn(response.reply(11)); + assert!( + timeout(Duration::from_millis(40), &mut returned) + .await + .is_err() + ); + assert!(!reply.is_closed()); + // Stand in for the Host writing its conversation: the call must not be + // delivered until this has happened. + let record = json!({ + "requested": pending.call.request.arguments, + "executed": recording.arguments, + "result": recording.result.to_text(), + }); + fs::write( + fixture.root.path().join("record.json"), + serde_json::to_vec(&record).unwrap(), + ) + .unwrap(); + reply.send(Ok(())).unwrap(); + assert_eq!( + returned.await.unwrap(), + json!({"jsonrpc":"2.0","id":11,"result":{"content":[{"type":"text","text":"approved output"}],"isError":false}}) + ); + let stored: Value = + serde_json::from_slice(&fs::read(fixture.root.path().join("record.json")).unwrap()) + .unwrap(); + assert_eq!( + stored, + json!({"requested":{"value":"requested"},"executed":{"value":"edited"},"result":"approved output"}) + ); + let listing = client + .request(12, "tools/list", json!({})) + .await + .reply(12) + .await; + assert_eq!( + listing["result"]["tools"][0]["_meta"], + json!({"anthropic/maxResultSizeChars":500_000,"fixture/hint":{"opaque":true}}) + ); + client.close().await; + fixture.shutdown().await; +} + +#[tokio::test] +#[expect( + clippy::too_many_lines, + reason = "The interleaved calls and their replies are asserted in protocol order" +)] +async fn host_and_external_client_share_handlers_without_sharing_call_identity() { + let (mut fixture, count) = counting_fixture().await; + let host_client = fixture.endpoint.connect().await.unwrap(); + let external = ExternalClient::connect(fixture.endpoint.url()).await; + let mut params = CallToolRequestParams::new("probe"); + params.arguments = Some(Map::new()); + params.meta = Some(Meta( + json!({"fixture/call":"host"}).as_object().unwrap().clone(), + )); + let peer = host_client.peer().clone(); + let host_result = tokio::spawn(async move { peer.call_tool(params).await.unwrap() }); + let first = next(&mut fixture.host).await; + let first_id = first.call.id; + assert_eq!(first.call.request.correlation["fixture/call"], "host"); + let Interaction::Prepare { + reply: first_reply, .. + } = first.interaction + else { + panic!("expected first preparation") + }; + let external_response = external + .request( + 17, + "tools/call", + json!({"name":"probe","arguments":{},"_meta":{"fixture/call":"external"}}), + ) + .await; + let second = next(&mut fixture.host).await; + let second_id = second.call.id; + assert_ne!(first_id, second_id); + assert_eq!( + second.call.request.correlation, + json!({"fixture/call":"external"}) + .as_object() + .unwrap() + .clone() + ); + assert_eq!(first.call.request.arguments, second.call.request.arguments); + assert_eq!(count.load(Ordering::SeqCst), 0); + let Interaction::Prepare { + arguments, reply, .. + } = second.interaction + else { + panic!("expected second preparation while first waits") + }; + reply.send(Ok(Admission::Run { arguments })).unwrap(); + let release = next(&mut fixture.host).await; + assert_eq!(release.call.id, second_id); + let Interaction::Release { reply, .. } = release.interaction else { + panic!("expected second release") + }; + reply.send(Ok(ReleaseDecision::Execute)).unwrap(); + let review = next(&mut fixture.host).await; + assert_eq!(review.call.id, second_id); + let Interaction::Review { result, reply, .. } = review.interaction else { + panic!("expected second review") + }; + assert_eq!(result, ToolResult::text("execution-1")); + reply.send(Ok(ToolResult::text("external result"))).unwrap(); + let record = next(&mut fixture.host).await; + assert_eq!(record.call.id, second_id); + let Interaction::Record { reply, .. } = record.interaction else { + panic!("expected second record") + }; + reply.send(Ok(())).unwrap(); + assert_eq!( + external_response.reply(17).await, + json!({"jsonrpc":"2.0","id":17,"result":{"content":[{"type":"text","text":"external result"}],"isError":false}}) + ); + assert!(!host_result.is_finished()); + assert!(!first_reply.is_closed()); + first_reply + .send(Ok(Admission::Run { + arguments: Map::new(), + })) + .unwrap(); + let release = next(&mut fixture.host).await; + assert_eq!(release.call.id, first_id); + let Interaction::Release { reply, .. } = release.interaction else { + panic!("expected first release") + }; + reply.send(Ok(ReleaseDecision::Execute)).unwrap(); + let review = next(&mut fixture.host).await; + assert_eq!(review.call.id, first_id); + let Interaction::Review { result, reply, .. } = review.interaction else { + panic!("expected first review") + }; + assert_eq!(result, ToolResult::text("execution-2")); + reply.send(Ok(ToolResult::text("host result"))).unwrap(); + let record = next(&mut fixture.host).await; + assert_eq!(record.call.id, first_id); + let Interaction::Record { reply, .. } = record.interaction else { + panic!("expected first record") + }; + reply.send(Ok(())).unwrap(); + assert_eq!(host_result.await.unwrap().content, vec![Content::text( + "host result" + )]); + assert_eq!(count.load(Ordering::SeqCst), 2); + external.close().await; + host_client.cancel().await.unwrap(); + fixture.shutdown().await; +} + +#[tokio::test] +async fn disconnected_response_resumes_without_reexecuting_tool() { + let (mut fixture, count) = counting_fixture().await; + let external = ExternalClient::connect(fixture.endpoint.url()).await; + let mut response = external + .request(21, "tools/call", json!({"name":"probe","arguments":{}})) + .await; + let (event_id, data) = response.frame().await; + assert_eq!(data, None); + let event_id = event_id.expect("request stream must provide a resumption cursor"); + let prepared = next(&mut fixture.host).await; + let id = prepared.call.id; + let Interaction::Prepare { + arguments, reply, .. + } = prepared.interaction + else { + panic!("expected preparation") + }; + reply.send(Ok(Admission::Run { arguments })).unwrap(); + let Interaction::Release { reply, .. } = next(&mut fixture.host).await.interaction else { + panic!("expected release") + }; + reply.send(Ok(ReleaseDecision::Execute)).unwrap(); + let Interaction::Review { result, reply, .. } = next(&mut fixture.host).await.interaction + else { + panic!("expected review") + }; + assert_eq!(result, ToolResult::text("execution-1")); + reply.send(Ok(ToolResult::text("recorded result"))).unwrap(); + let record = next(&mut fixture.host).await; + assert_eq!(record.call.id, id); + let Interaction::Record { reply, .. } = record.interaction else { + panic!("expected recording") + }; + drop(response); + let resumed = external.resume(&event_id).await; + assert!( + !reply.is_closed(), + "HTTP disconnection must not cancel the invocation" + ); + reply.send(Ok(())).unwrap(); + assert_eq!( + resumed.reply(21).await, + json!({"jsonrpc":"2.0","id":21,"result":{"content":[{"type":"text","text":"recorded result"}],"isError":false}}) + ); + assert_eq!(count.load(Ordering::SeqCst), 1); + assert!(matches!(fixture.host.try_recv(), Err(TryRecvError::Empty))); + external.close().await; + fixture.shutdown().await; +} + +#[tokio::test] +async fn failed_recording_returns_error_instead_of_the_tool_result() { + let (mut fixture, count) = counting_fixture().await; + let external = ExternalClient::connect(fixture.endpoint.url()).await; + let response = external + .request(23, "tools/call", json!({"name":"probe","arguments":{}})) + .await; + let Interaction::Prepare { + arguments, reply, .. + } = next(&mut fixture.host).await.interaction + else { + panic!("expected preparation") + }; + reply.send(Ok(Admission::Run { arguments })).unwrap(); + let Interaction::Release { reply, .. } = next(&mut fixture.host).await.interaction else { + panic!("expected release") + }; + reply.send(Ok(ReleaseDecision::Execute)).unwrap(); + let Interaction::Review { reply, .. } = next(&mut fixture.host).await.interaction else { + panic!("expected review") + }; + reply.send(Ok(ToolResult::text("approved result"))).unwrap(); + let Interaction::Record { reply, .. } = next(&mut fixture.host).await.interaction else { + panic!("expected recording") + }; + reply + .send(Err(HostError::Recording(Arc::new(io::Error::other( + "disk full", + ))))) + .unwrap(); + assert_eq!( + response.reply(23).await, + json!({"jsonrpc":"2.0","id":23,"error":{"code":-32603,"message":"MCP Host operation failed: disk full"}}) + ); + assert_eq!(count.load(Ordering::SeqCst), 1); + external.close().await; + fixture.shutdown().await; +} + +#[tokio::test] +async fn host_loss_closes_an_outstanding_approval_without_execution() { + let (mut fixture, count) = counting_fixture().await; + let external = ExternalClient::connect(fixture.endpoint.url()).await; + let response = external + .request(25, "tools/call", json!({"name":"probe","arguments":{}})) + .await; + let Interaction::Prepare { mut reply, .. } = next(&mut fixture.host).await.interaction else { + panic!("expected preparation") + }; + drop(fixture.host); + timeout(Duration::from_secs(5), reply.closed()) + .await + .unwrap(); + assert!( + reply + .send(Ok(Admission::Run { + arguments: Map::new() + })) + .is_err() + ); + assert_eq!( + response.reply(25).await, + json!({"jsonrpc":"2.0","id":25,"error":{"code":-32603,"message":"MCP Host disconnected before completing the interaction"}}) + ); + assert_eq!(count.load(Ordering::SeqCst), 0); + external.close().await; + fixture.endpoint.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn malformed_arguments_are_rejected_before_approval() { + let count = Arc::new(AtomicUsize::new(0)); + let mut fixture = integer_argument_fixture(&count).await; + let external = ExternalClient::connect(fixture.endpoint.url()).await; + let response = external + .request( + 27, + "tools/call", + json!({"name":"probe","arguments":{"value":"not an integer"}}), + ) + .await + .reply(27) + .await; + assert_eq!( + response, + json!({"jsonrpc":"2.0","id":27,"error":{"code":-32602,"message":"Invalid tool argument at `value`"}}) + ); + assert_eq!(count.load(Ordering::SeqCst), 0); + assert!(matches!(fixture.host.try_recv(), Err(TryRecvError::Empty))); + external.close().await; + fixture.shutdown().await; +} + +struct Inquiring(Arc); +#[async_trait] +impl BuiltinTool for Inquiring { + async fn execute(&self, _: &Value, answers: &IndexMap) -> Outcome { + self.0.fetch_add(1, Ordering::SeqCst); + if answers.get("confirm") == Some(&json!(true)) { + return "answered".into(); + } + Question::boolean("confirm", "Continue?").unwrap().into() + } +} + +async fn release(host: &mut HostReceiver) { + let request = next(host).await; + let id = request.call.id; + let Interaction::Prepare { + arguments, reply, .. + } = request.interaction + else { + panic!("expected preparation") + }; + reply.send(Ok(Admission::Run { arguments })).unwrap(); + let request = next(host).await; + assert_eq!(request.call.id, id); + let Interaction::Release { reply, .. } = request.interaction else { + panic!("expected release") + }; + reply.send(Ok(ReleaseDecision::Execute)).unwrap(); +} + +#[tokio::test] +async fn cancellation_is_scoped_to_the_requesting_client_session() { + let count = Arc::new(AtomicUsize::new(0)); + let mut fixture = fixture( + json!({"source":"builtin", "run":"ask", "result":"unattended"}), + BuiltinExecutors::new().register("probe", Inquiring(count.clone())), + ) + .await; + let first = ExternalClient::connect(fixture.endpoint.url()).await; + let second = ExternalClient::connect(fixture.endpoint.url()).await; + assert_ne!(first.session, second.session); + let first_response = first + .request(31, "tools/call", json!({"name":"probe","arguments":{}})) + .await; + release(&mut fixture.host).await; + let first_input = next(&mut fixture.host).await; + let Interaction::Input { + reply: mut first_reply, + .. + } = first_input.interaction + else { + panic!("expected first input") + }; + let second_response = second + .request(31, "tools/call", json!({"name":"probe","arguments":{}})) + .await; + release(&mut fixture.host).await; + let second_input = next(&mut fixture.host).await; + assert_ne!(first_input.call.id, second_input.call.id); + let Interaction::Input { + reply: second_reply, + .. + } = second_input.interaction + else { + panic!("expected second input") + }; + assert_eq!(count.load(Ordering::SeqCst), 2); + first + .notify( + "notifications/cancelled", + json!({"requestId":31,"reason":"fixture cancellation"}), + ) + .await; + timeout(Duration::from_secs(5), first_reply.closed()) + .await + .unwrap(); + assert!( + first_reply + .send(Ok(InputAnswer::Answer(json!(true)))) + .is_err() + ); + assert!(!second_reply.is_closed()); + second_reply + .send(Ok(InputAnswer::Answer(json!(true)))) + .unwrap(); + let record = next(&mut fixture.host).await; + assert_eq!(record.call.id, second_input.call.id); + let Interaction::Record { recording, reply } = record.interaction else { + panic!("expected only the second result") + }; + assert_eq!(recording.result, ToolResult::text("answered")); + reply.send(Ok(())).unwrap(); + assert_eq!( + second_response.reply(31).await, + json!({"jsonrpc":"2.0","id":31,"result":{"content":[{"type":"text","text":"answered"}],"isError":false}}) + ); + assert_eq!(count.load(Ordering::SeqCst), 3); + drop(first_response); + first.close().await; + second.close().await; + fixture.shutdown().await; +} + +struct LargeResult(String); +#[async_trait] +impl BuiltinTool for LargeResult { + async fn execute(&self, _: &Value, _: &IndexMap) -> Outcome { + self.0.clone().into() + } +} + +#[tokio::test] +async fn large_result_reaches_external_client_byte_for_byte() { + // 240 KB, well past any single chunk the transport reads. A repetitive + // fixed payload avoids a large checked-in fixture, and comparing the entire + // value catches truncation, duplication, and newline changes. + let payload = "line\n".repeat(48_000); + let mut fixture = fixture( + json!({"source":"builtin", "run":"ask", "result":"unattended"}), + BuiltinExecutors::new().register("probe", LargeResult(payload.clone())), + ) + .await; + let external = ExternalClient::connect(fixture.endpoint.url()).await; + let response = external + .request(33, "tools/call", json!({"name":"probe","arguments":{}})) + .await; + release(&mut fixture.host).await; + let Interaction::Record { recording, reply } = next(&mut fixture.host).await.interaction else { + panic!("expected record") + }; + assert_eq!(recording.result, ToolResult::text(payload.clone())); + reply.send(Ok(())).unwrap(); + let result = response.reply(33).await; + assert_eq!( + result, + json!({"jsonrpc":"2.0","id":33,"result":{"content":[{"type":"text","text":payload}],"isError":false}}) + ); + external.close().await; + fixture.shutdown().await; +} + +#[tokio::test] +async fn external_denial_never_executes_the_tool() { + let (mut fixture, count) = counting_fixture().await; + let external = ExternalClient::connect(fixture.endpoint.url()).await; + let response = external + .request(35, "tools/call", json!({"name":"probe","arguments":{}})) + .await; + let Interaction::Prepare { reply, .. } = next(&mut fixture.host).await.interaction else { + panic!("expected approval") + }; + reply + .send(Ok(Admission::Skip { + reason: "denied by Host".into(), + })) + .unwrap(); + let Interaction::Record { recording, reply } = next(&mut fixture.host).await.interaction else { + panic!("expected recording without release") + }; + assert_eq!(recording.raw_result, None); + reply.send(Ok(())).unwrap(); + assert_eq!( + response.reply(35).await, + json!({"jsonrpc":"2.0","id":35,"result":{"content":[{"type":"text","text":"denied by Host"}],"isError":false}}) + ); + assert_eq!(count.load(Ordering::SeqCst), 0); + external.close().await; + fixture.shutdown().await; +} + +#[tokio::test] +async fn edited_arguments_are_checked_before_execution_release() { + let count = Arc::new(AtomicUsize::new(0)); + let mut fixture = integer_argument_fixture(&count).await; + let external = ExternalClient::connect(fixture.endpoint.url()).await; + let response = external + .request( + 37, + "tools/call", + json!({"name":"probe","arguments":{"value":1}}), + ) + .await; + let Interaction::Prepare { reply, .. } = next(&mut fixture.host).await.interaction else { + panic!("expected valid initial arguments") + }; + reply + .send(Ok(Admission::Run { + arguments: json!({"value":"invalid edit"}).as_object().unwrap().clone(), + })) + .unwrap(); + assert_eq!( + response.reply(37).await, + json!({"jsonrpc":"2.0","id":37,"error":{"code":-32602,"message":"Invalid tool argument at `value`"}}) + ); + assert_eq!(count.load(Ordering::SeqCst), 0); + assert!(matches!(fixture.host.try_recv(), Err(TryRecvError::Empty))); + external.close().await; + fixture.shutdown().await; +} diff --git a/crates/jp_mcp/src/server/http.rs b/crates/jp_mcp/src/server/http.rs new file mode 100644 index 000000000..85c9a8356 --- /dev/null +++ b/crates/jp_mcp/src/server/http.rs @@ -0,0 +1,384 @@ +//! Loopback Streamable HTTP transport for the JP MCP Server. +//! +//! [`Endpoint`] owns its listener and execution service. +//! The private Host receiver returned when constructing the service remains +//! with the MCP Host. + +use std::{ + error::Error as StdError, + io, + net::{Ipv4Addr, SocketAddr}, + sync::Arc, + time::{Duration, Instant}, +}; + +use axum::Router; +use jp_tool::Error as ToolError; +use rmcp::{ + ErrorData, ServerHandler, ServiceExt as _, + model::{ + CallToolRequestParams, CallToolResult, ListToolsResult, Meta, PaginatedRequestParams, + ProgressNotificationParam, ProgressToken, ServerCapabilities, ServerInfo, Tool, + }, + service::{Peer, RequestContext, RoleClient, RoleServer, RunningService}, + transport::{ + StreamableHttpClientTransport, + streamable_http_client::StreamableHttpClientTransportConfig, + streamable_http_server::{ + session::local::LocalSessionManager, + tower::{StreamableHttpServerConfig, StreamableHttpService}, + }, + }, +}; +use tokio::{ + net::TcpListener, + sync::broadcast, + task::{JoinError, JoinHandle}, + time::{MissedTickBehavior, interval}, +}; +use tokio_util::sync::CancellationToken; +use url::Url; + +use super::{ + http_client::LoopbackClient, + service::{CallRequest, InvocationId, Progress, Service, ServiceError}, +}; + +/// How often a running call tells its caller that it is still alive, when the +/// tool itself has nothing to say. +/// +/// A caller waiting on a call it has heard nothing from cannot tell a slow tool +/// from a dead one, and clients commonly abandon such a call after a few +/// minutes. +/// This sits well inside those limits, and inside the idle window the transport +/// applies to a session carrying no traffic. +const PROGRESS_HEARTBEAT: Duration = Duration::from_secs(30); + +/// Failure starting, connecting to, or stopping the in-process endpoint. +#[derive(Debug, thiserror::Error)] +pub enum EndpointError { + /// Listener or HTTP server I/O failed. + #[error(transparent)] + Io(#[from] io::Error), + /// The HTTP task failed. + #[error(transparent)] + Task(#[from] JoinError), + /// The tool catalog the Host supplied cannot be served. + #[error(transparent)] + Service(#[from] ServiceError), + /// The MCP handshake failed. + #[error("Could not connect to JP MCP Server: {0}")] + Connect(Box), +} + +/// Open an independent MCP session using JP's HTTP transport. +/// Expired sessions fail rather than replaying tool execution automatically. +pub async fn connect(url: &Url) -> Result, EndpointError> { + // A loopback connection must not be routed through an environment + // proxy or followed to another host. + let client = LoopbackClient::new().map_err(|error| EndpointError::Connect(Box::new(error)))?; + let config = StreamableHttpClientTransportConfig::with_uri(url.to_string()) + .reinit_on_expired_session(false); + ().serve(StreamableHttpClientTransport::with_client(client, config)) + .await + .map_err(|error| EndpointError::Connect(Box::new(error))) +} + +/// Owns a loopback listener with an OS-assigned port. +pub struct Endpoint { + url: String, + service: Arc, + cancellation: CancellationToken, + task: Option>>, +} + +impl Endpoint { + /// Start the endpoint. + /// Does not consume or drive the private Host receiver. + pub async fn start(service: Service) -> Result { + Self::start_reporting_every(service, PROGRESS_HEARTBEAT).await + } + + /// Start the endpoint, choosing how often a running call reports liveness. + /// + /// Separate from [`start`] so a test can observe repeated reports without + /// waiting out the interval a real caller is served. + /// + /// [`start`]: Self::start + async fn start_reporting_every( + service: Service, + heartbeat: Duration, + ) -> Result { + let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).await?; + let address = listener.local_addr()?; + let origin = format!("http://{address}"); + let url = format!("{origin}/mcp"); + let service = Arc::new(service); + let factory = service.clone(); + let cancellation = CancellationToken::new(); + // Only this listener's own address is an acceptable Host or Origin, so + // a page in a browser cannot reach the endpoint by resolving some other + // name to loopback. + // + // Assigned field by field because rmcp marks the config + // `#[non_exhaustive]`, which rules out struct-update syntax downstream. + let mut config = StreamableHttpServerConfig::default(); + config.allowed_hosts = vec![address.to_string()]; + config.allowed_origins = vec![origin]; + config.cancellation_token = cancellation.clone(); + let transport = StreamableHttpService::new( + move || { + Ok(Handler { + service: factory.clone(), + heartbeat, + }) + }, + Arc::new(LocalSessionManager::default()), + config, + ); + let router = Router::new().nest_service("/mcp", transport); + let shutdown = cancellation.clone(); + let task = tokio::spawn(async move { + axum::serve(listener, router) + .with_graceful_shutdown(shutdown.cancelled_owned()) + .await + }); + Ok(Self { + url, + service, + cancellation, + task: Some(task), + }) + } + + /// URL provided to MCP callers; JP's terminal stdio is not used. + #[must_use] + pub fn url(&self) -> &str { + &self.url + } + + /// Establish the MCP Host's ordinary HTTP connection to this endpoint. + pub async fn connect(&self) -> Result, EndpointError> { + let url = self + .url + .parse() + .map_err(|error| EndpointError::Connect(Box::new(error)))?; + connect(&url).await + } + + /// Private in-process control for the MCP Host, not exposed through HTTP. + #[must_use] + pub fn service(&self) -> Arc { + self.service.clone() + } + + /// Signal cancellation of current calls without closing the endpoint. + pub fn cancel_current(&self) { + self.service.cancel_current(); + } + + /// Stop tool work, close upstream services, and join the HTTP listener. + pub async fn shutdown(mut self) -> Result<(), EndpointError> { + self.service.shutdown().await; + self.cancellation.cancel(); + if let Some(task) = self.task.take() { + task.await??; + } + Ok(()) + } +} + +impl Drop for Endpoint { + fn drop(&mut self) { + self.service.stop(); + self.cancellation.cancel(); + } +} + +#[derive(Clone)] +struct Handler { + service: Arc, + + /// How often a running call reports liveness while its tool is silent. + heartbeat: Duration, +} + +/// Reports one call's progress for as long as it is held. +/// +/// The work runs in its own task so that it continues for the whole call rather +/// than only while the handler happens to be waiting on something. +struct Reporter(JoinHandle<()>); + +impl Drop for Reporter { + fn drop(&mut self) { + self.0.abort(); + } +} + +/// Report a running call's output and liveness to the caller that asked for it. +/// +/// Each line the tool writes to stderr becomes one notification, and a stretch +/// in which it writes nothing produces one every `heartbeat`. +/// The count rises by one per notification and carries no total, which is what +/// MCP asks of work whose size is not known in advance. +/// +/// Returns immediately when the caller supplied no progress token, since a +/// notification has nowhere to go without one. +async fn report_progress( + peer: Peer, + id: InvocationId, + progress: Option<(ProgressToken, broadcast::Receiver)>, + heartbeat: Duration, +) { + let Some((token, mut lines)) = progress else { + return; + }; + let mut ticker = interval(heartbeat); + // Catching up on the ticks missed during a burst of tool output would spend + // them all at once, on a call that plainly needs no reminder that it is + // alive. + ticker.set_missed_tick_behavior(MissedTickBehavior::Delay); + // The first tick is immediate, and the caller has just been told the call + // began. + ticker.tick().await; + let started = Instant::now(); + let mut count = 0_f64; + loop { + let message = tokio::select! { + received = lines.recv() => match received { + Ok(progress) if progress.id == id => progress.line, + // A line from another call in flight, or more lines than this + // receiver kept up with. Neither says anything about this call, + // and the receiver stays usable either way. + Ok(_) | Err(broadcast::error::RecvError::Lagged(_)) => continue, + // The service owning the sender is gone, so the call cannot + // still be running. + Err(broadcast::error::RecvError::Closed) => return, + }, + _ = ticker.tick() => format!("running for {}s", started.elapsed().as_secs()), + }; + count += 1.0; + let param = ProgressNotificationParam::new(token.clone(), count).with_message(message); + // A caller that stopped listening is no reason to stop the tool. + if peer.notify_progress(param).await.is_err() { + return; + } + } +} + +impl ServerHandler for Handler { + fn get_info(&self) -> ServerInfo { + // Assigned field by field because rmcp marks `ServerInfo` + // `#[non_exhaustive]`, which rules out struct-update syntax downstream. + let mut info = ServerInfo::default(); + info.server_info.name = "jp".into(); + info.server_info.version = env!("CARGO_PKG_VERSION").into(); + info.capabilities = ServerCapabilities::builder().enable_tools().build(); + info + } + + async fn list_tools( + &self, + _: Option, + _: RequestContext, + ) -> Result { + let tools = self + .service + .definitions() + .map(|definition| { + let mut tool = Tool::new( + definition.name.clone(), + definition + .docs + .schema_description() + .unwrap_or_default() + .to_owned(), + Arc::new( + definition + .parameters + .as_object() + .cloned() + .unwrap_or_default(), + ), + ); + tool.meta = self + .service + .tool_metadata(&definition.name) + .cloned() + .map(Meta); + tool + }) + .collect(); + Ok(ListToolsResult { + tools, + ..Default::default() + }) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + if request.task.is_some() { + return Err(ErrorData::invalid_params( + "JP tool calls do not support MCP tasks", + None, + )); + } + // Subscribed before the call is submitted, because a broadcast receiver + // is sent only what is broadcast after it subscribes, and submitting the + // call starts the tool. + let progress = context + .meta + .get_progress_token() + .map(|token| (token, self.service.subscribe_progress())); + let call = self + .service + .start_call(CallRequest { + name: request.name.into_owned(), + arguments: request.arguments.unwrap_or_default(), + correlation: context.meta.0, + }) + .map_err(protocol_error)?; + let cancellation = call.cancellation_token(); + // Explicit MCP cancellation or handler destruction must not orphan the + // separately owned invocation. A dropped HTTP response stream alone + // does not destroy a stateful session's request handler. + let _guard = cancellation.clone().drop_guard(); + // Reporting ends with the call, however the call ends. + let _reporter = Reporter(tokio::spawn(report_progress( + context.peer.clone(), + call.id(), + progress, + self.heartbeat, + ))); + let result = call.finish_mcp(); + tokio::pin!(result); + tokio::select! { + biased; + () = context.ct.cancelled() => { cancellation.cancel(); result.await.map_err(protocol_error) }, + result = &mut result => result.map_err(protocol_error), + } + } +} + +fn protocol_error(error: ServiceError) -> ErrorData { + match error { + ServiceError::Tool(ToolError::NotFound { name }) => { + ErrorData::invalid_params(format!("Unknown tool: {name}"), None) + } + ServiceError::InvalidArgument { path } => { + ErrorData::invalid_params(format!("Invalid tool argument at `{path}`"), None) + } + other => ErrorData::internal_error(other.to_string(), None), + } +} + +#[cfg(test)] +#[path = "http_tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "conformance_tests.rs"] +mod conformance_tests; diff --git a/crates/jp_mcp/src/server/http_client.rs b/crates/jp_mcp/src/server/http_client.rs new file mode 100644 index 000000000..fc9f9f0bd --- /dev/null +++ b/crates/jp_mcp/src/server/http_client.rs @@ -0,0 +1,200 @@ +//! Reqwest transport for the MCP Host's connection to its own HTTP endpoint. +//! +//! MCP session handling, cancellation, and SSE resumption belong to rmcp's +//! transport worker. +//! This adapter sends HTTP requests and decodes responses. + +use std::{collections::HashMap, sync::Arc}; + +use futures::{StreamExt as _, stream::BoxStream}; +use reqwest::{ + Client, Error, RequestBuilder, Response, StatusCode, + header::{ACCEPT, CONTENT_TYPE, HeaderName, HeaderValue}, + redirect::Policy, +}; +use rmcp::{ + model::ClientJsonRpcMessage, + transport::streamable_http_client::{ + StreamableHttpClient, StreamableHttpError, StreamableHttpPostResponse, + }, +}; +use sse_stream::{Error as SseError, Sse, SseStream}; + +/// HTTP client for JP's private loopback connection, without proxies or +/// redirects. +#[derive(Clone)] +pub(super) struct LoopbackClient(Client); + +impl LoopbackClient { + /// Construct a client that cannot route the local connection through a + /// proxy. + pub(super) fn new() -> Result { + Ok(Self( + Client::builder() + .no_proxy() + .redirect(Policy::none()) + .build()?, + )) + } +} + +fn request_headers( + mut request: RequestBuilder, + session: Option<&str>, + auth: Option, + headers: HashMap, +) -> Result> { + for (name, value) in headers { + if matches!( + name.as_str(), + "accept" + | "content-type" + | "mcp-session-id" + | "last-event-id" + | "authorization" + | "host" + ) { + return Err(StreamableHttpError::ReservedHeaderConflict( + name.to_string(), + )); + } + request = request.header(name, value); + } + if let Some(session) = session { + request = request.header("mcp-session-id", session); + } + if let Some(auth) = auth { + request = request.bearer_auth(auth); + } + Ok(request) +} + +fn content_type(response: &Response) -> Result<&str, StreamableHttpError> { + let value = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()); + value + .map(|value| value.split(';').next().unwrap_or(value).trim()) + .ok_or(StreamableHttpError::UnexpectedContentType(None)) +} + +fn event_stream(response: Response) -> BoxStream<'static, Result> { + SseStream::from_bytes_stream(response.bytes_stream()).boxed() +} + +impl StreamableHttpClient for LoopbackClient { + type Error = Error; + + async fn post_message( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session_id: Option>, + auth_header: Option, + custom_headers: HashMap, + ) -> Result> { + let request = self + .0 + .post(uri.as_ref()) + .header(ACCEPT, "application/json, text/event-stream") + .json(&message); + let response = + request_headers(request, session_id.as_deref(), auth_header, custom_headers)? + .send() + .await + .map_err(StreamableHttpError::Client)?; + if response.status() == StatusCode::NOT_FOUND && session_id.is_some() { + return Err(StreamableHttpError::SessionExpired); + } + let response = response + .error_for_status() + .map_err(StreamableHttpError::Client)?; + if matches!( + response.status(), + StatusCode::ACCEPTED | StatusCode::NO_CONTENT + ) { + return Ok(StreamableHttpPostResponse::Accepted); + } + let session = response + .headers() + .get("mcp-session-id") + .map(|value| value.to_str().map(str::to_owned)) + .transpose() + .map_err(|_| { + StreamableHttpError::UnexpectedServerResponse("invalid MCP session header".into()) + })?; + match content_type(&response)? { + "application/json" => Ok(StreamableHttpPostResponse::Json( + response.json().await.map_err(StreamableHttpError::Client)?, + session, + )), + "text/event-stream" => Ok(StreamableHttpPostResponse::Sse( + event_stream(response), + session, + )), + other => Err(StreamableHttpError::UnexpectedContentType(Some( + other.into(), + ))), + } + } + + async fn delete_session( + &self, + uri: Arc, + session_id: Arc, + auth_header: Option, + custom_headers: HashMap, + ) -> Result<(), StreamableHttpError> { + let request = self.0.delete(uri.as_ref()); + let response = request_headers(request, Some(&session_id), auth_header, custom_headers)? + .send() + .await + .map_err(StreamableHttpError::Client)?; + if response.status() == StatusCode::METHOD_NOT_ALLOWED { + return Err(StreamableHttpError::ServerDoesNotSupportDeleteSession); + } + response + .error_for_status() + .map_err(StreamableHttpError::Client)?; + Ok(()) + } + + async fn get_stream( + &self, + uri: Arc, + session_id: Arc, + last_event_id: Option, + auth_header: Option, + custom_headers: HashMap, + ) -> Result>, StreamableHttpError> { + let mut request = self.0.get(uri.as_ref()).header(ACCEPT, "text/event-stream"); + if let Some(id) = last_event_id { + request = request.header("last-event-id", id); + } + let response = request_headers(request, Some(&session_id), auth_header, custom_headers)? + .send() + .await + .map_err(StreamableHttpError::Client)?; + match response.status() { + StatusCode::METHOD_NOT_ALLOWED => { + return Err(StreamableHttpError::ServerDoesNotSupportSse); + } + StatusCode::NOT_FOUND => return Err(StreamableHttpError::SessionExpired), + _ => {} + } + let response = response + .error_for_status() + .map_err(StreamableHttpError::Client)?; + if content_type(&response)? != "text/event-stream" { + return Err(StreamableHttpError::UnexpectedContentType(Some( + content_type(&response)?.into(), + ))); + } + Ok(event_stream(response)) + } +} + +#[cfg(test)] +#[path = "http_client_tests.rs"] +mod tests; diff --git a/crates/jp_mcp/src/server/http_client_tests.rs b/crates/jp_mcp/src/server/http_client_tests.rs new file mode 100644 index 000000000..514dc009e --- /dev/null +++ b/crates/jp_mcp/src/server/http_client_tests.rs @@ -0,0 +1,156 @@ +use axum::{Router, body::Body, extract::Request, http::Response, routing::any}; +use reqwest::StatusCode; +use serde_json::json; +use tokio::{net::TcpListener, task::JoinHandle}; + +use super::*; + +async fn fixture( + status: StatusCode, + content_type: &'static str, + body: &'static str, +) -> (Arc, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url: Arc = format!("http://{}/mcp", listener.local_addr().unwrap()).into(); + let router = Router::new().route( + "/mcp", + any(move |request: Request| async move { + assert_eq!(request.headers()["mcp-session-id"], "session-1"); + assert_eq!(request.headers()["mcp-protocol-version"], "2025-11-25"); + Response::builder() + .status(status) + .header("content-type", content_type) + .body(Body::from(body)) + .unwrap() + }), + ); + let task = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + (url, task) +} + +fn headers() -> HashMap { + HashMap::from([( + HeaderName::from_static("mcp-protocol-version"), + HeaderValue::from_static("2025-11-25"), + )]) +} + +#[tokio::test] +async fn post_decodes_json_response() { + let (url, server) = fixture( + StatusCode::OK, + "application/json; charset=utf-8", + r#"{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}"#, + ) + .await; + let message = + serde_json::from_value(json!({"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}})) + .unwrap(); + let result = LoopbackClient::new() + .unwrap() + .post_message(url, message, Some("session-1".into()), None, headers()) + .await + .unwrap(); + let StreamableHttpPostResponse::Json(message, session) = result else { + panic!("expected JSON response") + }; + assert_eq!(session, None); + assert_eq!( + serde_json::to_value(message).unwrap(), + json!({"jsonrpc":"2.0","id":1,"result":{"tools":[]}}) + ); + server.abort(); +} + +#[tokio::test] +async fn expired_session_is_not_a_new_request() { + let (url, server) = fixture(StatusCode::NOT_FOUND, "text/plain", "expired").await; + let message = + serde_json::from_value(json!({"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}})) + .unwrap(); + let result = LoopbackClient::new() + .unwrap() + .post_message(url, message, Some("session-1".into()), None, headers()) + .await; + assert!(matches!(result, Err(StreamableHttpError::SessionExpired))); + server.abort(); +} + +#[tokio::test] +async fn unsupported_stream_is_explicit() { + let (url, server) = fixture(StatusCode::METHOD_NOT_ALLOWED, "text/plain", "unsupported").await; + let result = LoopbackClient::new() + .unwrap() + .get_stream(url, "session-1".into(), None, None, headers()) + .await; + assert!(matches!( + result, + Err(StreamableHttpError::ServerDoesNotSupportSse) + )); + server.abort(); +} + +#[tokio::test] +async fn unsupported_deletion_is_explicit() { + let (url, server) = fixture(StatusCode::METHOD_NOT_ALLOWED, "text/plain", "unsupported").await; + let result = LoopbackClient::new() + .unwrap() + .delete_session(url, "session-1".into(), None, headers()) + .await; + assert!(matches!( + result, + Err(StreamableHttpError::ServerDoesNotSupportDeleteSession) + )); + server.abort(); +} + +#[tokio::test] +async fn get_stream_preserves_event_ids_and_data() { + let (url, server) = fixture( + StatusCode::OK, + "text/event-stream", + "id: event-1\nevent: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}\n\n", + ) + .await; + let mut stream = LoopbackClient::new() + .unwrap() + .get_stream(url, "session-1".into(), None, None, headers()) + .await + .unwrap(); + let event = stream.next().await.unwrap().unwrap(); + assert_eq!(event.id.as_deref(), Some("event-1")); + assert_eq!( + event.data.as_deref(), + Some(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#) + ); + assert!(stream.next().await.is_none()); + server.abort(); +} + +#[test] +fn caller_headers_cannot_override_the_session() { + let request = Client::new().get("http://127.0.0.1/mcp"); + let headers = HashMap::from([( + HeaderName::from_static("mcp-session-id"), + HeaderValue::from_static("wrong-session"), + )]); + let result = request_headers(request, Some("session-1"), None, headers); + assert!( + matches!(result, Err(StreamableHttpError::ReservedHeaderConflict(name)) if name == "mcp-session-id") + ); +} + +#[tokio::test] +async fn unexpected_content_type_is_rejected() { + let (url, server) = fixture(StatusCode::OK, "text/html", "not MCP").await; + let result = LoopbackClient::new() + .unwrap() + .get_stream(url, "session-1".into(), None, None, headers()) + .await; + assert!( + matches!(result, Err(StreamableHttpError::UnexpectedContentType(Some(value))) if value == "text/html") + ); + server.abort(); +} diff --git a/crates/jp_mcp/src/server/http_tests.rs b/crates/jp_mcp/src/server/http_tests.rs new file mode 100644 index 000000000..0cab1ef74 --- /dev/null +++ b/crates/jp_mcp/src/server/http_tests.rs @@ -0,0 +1,260 @@ +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use async_trait::async_trait; +use indexmap::IndexMap; +use jp_config::{ + AppConfig, Config as _, + conversation::tool::{PartialToolConfig, ToolConfig}, +}; +use jp_tool::{Outcome, ToolDefinition, ToolDocs, ToolResult}; +use reqwest::Client as HttpClient; +use rmcp::model::CallToolRequestParams; +use serde_json::{Map, Value, json}; +use tokio::time::{Duration, timeout}; + +use super::*; +use crate::{ + Client, Content, + server::{ + InvocationContext, + builtin::{BuiltinExecutors, BuiltinTool}, + service::{ + Admission, CallRequest, ConfiguredTool, HostReceiver, Interaction, ReleaseDecision, + ServiceError, + }, + }, +}; + +struct Count(Arc); +#[async_trait] +impl BuiltinTool for Count { + async fn execute(&self, _: &Value, _: &IndexMap) -> Outcome { + self.0.fetch_add(1, Ordering::SeqCst); + "raw".into() + } +} + +fn setup() -> (Service, HostReceiver, Arc) { + let count = Arc::new(AtomicUsize::new(0)); + let partial: PartialToolConfig = + serde_json::from_value(json!({"source":"builtin", "run":"ask", "result":"edit"})).unwrap(); + let mut cfg = AppConfig::new_test(); + cfg.conversation.tools.insert( + "count".into(), + ToolConfig::from_partial(partial, vec![]).unwrap(), + ); + let (service, host) = Service::new( + vec![ConfiguredTool { + definition: ToolDefinition { + name: "count".into(), + docs: ToolDocs::default(), + parameters: json!({"type":"object","properties":{}}), + }, + config: cfg.conversation.tools.get("count").unwrap(), + access: Ok(None), + metadata: Map::new(), + }], + Client::default(), + BuiltinExecutors::new().register("count", Count(count.clone())), + "/tmp".into(), + InvocationContext::default(), + ) + .unwrap(); + (service, host, count) +} + +#[tokio::test] +async fn http_call_waits_for_host_release_and_records_edited_result() { + let (service, mut host, count) = setup(); + let endpoint = Endpoint::start(service).await.unwrap(); + let client = endpoint.connect().await.unwrap(); + let tools = client.peer().list_all_tools().await.unwrap(); + assert_eq!(tools[0].meta, None); + assert_eq!( + tools + .iter() + .map(|tool| tool.name.as_ref()) + .collect::>(), + ["count"] + ); + let peer = client.peer().clone(); + let task = + tokio::spawn(async move { peer.call_tool(CallToolRequestParams::new("count")).await }); + let Interaction::Prepare { + arguments, reply, .. + } = timeout(Duration::from_secs(2), host.recv()) + .await + .unwrap() + .unwrap() + .interaction + else { + panic!("expected preparation") + }; + assert_eq!(count.load(Ordering::SeqCst), 0); + reply.send(Ok(Admission::Run { arguments })).unwrap(); + let Interaction::Release { reply, .. } = host.recv().await.unwrap().interaction else { + panic!("expected release") + }; + assert_eq!(count.load(Ordering::SeqCst), 0); + reply.send(Ok(ReleaseDecision::Execute)).unwrap(); + let Interaction::Review { result, reply, .. } = host.recv().await.unwrap().interaction else { + panic!("expected review") + }; + assert_eq!(result, ToolResult::text("raw")); + reply.send(Ok(ToolResult::text("edited"))).unwrap(); + let Interaction::Record { recording, reply } = host.recv().await.unwrap().interaction else { + panic!("expected record") + }; + assert_eq!(recording.result, ToolResult::text("edited")); + assert!(!task.is_finished()); + reply.send(Ok(())).unwrap(); + let result = timeout(Duration::from_secs(2), task) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(result.content, vec![Content::text("edited")]); + assert_eq!(count.load(Ordering::SeqCst), 1); + client.cancel().await.unwrap(); + endpoint.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn http_rejects_untrusted_host_and_origin_before_dispatch() { + let (service, _host, count) = setup(); + let endpoint = Endpoint::start(service).await.unwrap(); + let client = HttpClient::builder().no_proxy().build().unwrap(); + let response = client + .post(endpoint.url()) + .header("host", "evil.example") + .body("{}") + .send() + .await + .unwrap(); + assert_eq!(response.status().as_u16(), 403); + let response = client + .post(endpoint.url()) + .header("origin", "https://evil.example") + .body("{}") + .send() + .await + .unwrap(); + assert_eq!(response.status().as_u16(), 403); + assert_eq!(count.load(Ordering::SeqCst), 0); + endpoint.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn http_denial_is_recorded_without_execution() { + let (service, mut host, count) = setup(); + let endpoint = Endpoint::start(service).await.unwrap(); + let client = endpoint.connect().await.unwrap(); + let peer = client.peer().clone(); + let task = + tokio::spawn(async move { peer.call_tool(CallToolRequestParams::new("count")).await }); + let Interaction::Prepare { reply, .. } = timeout(Duration::from_secs(2), host.recv()) + .await + .unwrap() + .unwrap() + .interaction + else { + panic!("expected preparation") + }; + reply + .send(Ok(Admission::Skip { + reason: "not approved".into(), + })) + .unwrap(); + let Interaction::Record { recording, reply } = timeout(Duration::from_secs(2), host.recv()) + .await + .unwrap() + .unwrap() + .interaction + else { + panic!("expected recording") + }; + assert_eq!(recording.raw_result, None); + assert_eq!(count.load(Ordering::SeqCst), 0); + reply.send(Ok(())).unwrap(); + let result = timeout(Duration::from_secs(2), task) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(result.content, vec![Content::text("not approved")]); + assert_eq!(count.load(Ordering::SeqCst), 0); + client.cancel().await.unwrap(); + endpoint.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn endpoint_shutdown_cancels_waiting_call_and_closes_listener() { + let (service, mut host, count) = setup(); + let endpoint = Endpoint::start(service).await.unwrap(); + let url = endpoint.url().to_owned(); + let client = endpoint.connect().await.unwrap(); + let peer = client.peer().clone(); + let task = + tokio::spawn(async move { peer.call_tool(CallToolRequestParams::new("count")).await }); + let Interaction::Prepare { reply, .. } = timeout(Duration::from_secs(2), host.recv()) + .await + .unwrap() + .unwrap() + .interaction + else { + panic!("expected preparation") + }; + timeout(Duration::from_secs(2), endpoint.shutdown()) + .await + .unwrap() + .unwrap(); + assert!( + reply + .send(Ok(Admission::Run { + arguments: Map::new() + })) + .is_err() + ); + assert!( + timeout(Duration::from_secs(2), task) + .await + .unwrap() + .unwrap() + .is_err() + ); + assert!( + HttpClient::builder() + .no_proxy() + .build() + .unwrap() + .post(url) + .body("{}") + .send() + .await + .unwrap_err() + .is_connect() + ); + assert_eq!(count.load(Ordering::SeqCst), 0); + client.cancel().await.unwrap(); +} + +#[tokio::test] +async fn dropping_endpoint_stops_admission_even_if_host_is_still_connected() { + let (service, _host, count) = setup(); + let endpoint = Endpoint::start(service).await.unwrap(); + let service = endpoint.service(); + drop(endpoint); + assert!(matches!( + service.start_call(CallRequest { + name: "count".into(), + arguments: Map::new(), + correlation: Map::new() + }), + Err(ServiceError::Stopped) + )); + assert_eq!(count.load(Ordering::SeqCst), 0); + service.shutdown().await; +} diff --git a/crates/jp_mcp/src/server/json_schema.rs b/crates/jp_mcp/src/server/json_schema.rs new file mode 100644 index 000000000..8148ab456 --- /dev/null +++ b/crates/jp_mcp/src/server/json_schema.rs @@ -0,0 +1,259 @@ +//! Building a tool's parameter schema from configuration. +//! +//! A tool's parameters are one JSON Schema object. +//! For an MCP tool that is the server's `inputSchema` with the user's +//! configured overrides applied; for a local or built-in tool it is generated +//! from configuration alone. +//! +//! Reading and validating the result lives in [`jp_tool::schema`], which knows +//! nothing about configuration. + +use indexmap::IndexMap; +use jp_config::conversation::tool::{OneOrManyTypes, ToolParameterConfig}; +use jp_tool::{ + Error, + schema::{Node, format_types, merge_description, required_names, validate_types}, +}; +use serde_json::{Map, Value, json}; + +/// Build the parameters schema for a tool whose shape is defined entirely in +/// configuration. +/// +/// Local and built-in tools have no upstream schema, so every parameter must +/// declare a type. +/// +/// # Errors +/// +/// Returns [`Error::InvalidSchema`] when a parameter declares no type, or one +/// the schema cannot carry. +pub fn from_config( + path: &str, + parameters: &IndexMap, +) -> Result { + let mut properties = Map::new(); + let mut required = vec![]; + + for (name, parameter) in parameters { + let node = node_from_config(&format!("{path}.{name}"), parameter)?; + if parameter.required.unwrap_or(false) { + required.push(Value::String(name.clone())); + } + properties.insert(name.clone(), node); + } + + Ok(object_schema(properties, required)) +} + +/// Apply configured overrides to a schema declared by an MCP server. +/// +/// The server's document is preserved, including any `$defs` block. +/// An override may narrow a parameter, but may not contradict the type the +/// server declared. +/// +/// # Errors +/// +/// Returns [`Error::InvalidSchema`] when an override contradicts the type the +/// server declared, or declares one the schema cannot carry. +pub fn with_overrides( + path: &str, + source: &Value, + overrides: &IndexMap, +) -> Result { + let mut schema = source.as_object().cloned().unwrap_or_default(); + let source_required = required_names(source); + + let mut properties = source + .get("properties") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let mut required = source_required + .iter() + .map(|name| Value::String((*name).to_owned())) + .collect::>(); + + for (name, override_config) in overrides { + let path = format!("{path}.{name}"); + let node = match properties.get(name) { + Some(node) => node_with_override(&path, node, source, override_config)?, + None => node_from_config(&path, override_config)?, + }; + properties.insert(name.clone(), node); + + // A server's requirement cannot be relaxed, only added to: dropping it + // would produce calls that omit an argument the server expects. + let named = Value::String(name.clone()); + if override_config.required == Some(true) && !required.contains(&named) { + required.push(named); + } + } + + schema.insert("properties".to_owned(), Value::Object(properties)); + schema.insert("required".to_owned(), Value::Array(required)); + schema.insert("type".to_owned(), Value::String("object".to_owned())); + + Ok(Value::Object(schema)) +} + +fn object_schema(properties: Map, required: Vec) -> Value { + json!({ + "type": "object", + "properties": Value::Object(properties), + "required": Value::Array(required), + }) +} + +/// Build one schema node from configuration alone. +fn node_from_config(path: &str, config: &ToolParameterConfig) -> Result { + let kind = config.kind.as_ref().ok_or_else(|| Error::InvalidSchema { + path: format!("{path}.type"), + message: "local and built-in tool parameters must declare a type".to_owned(), + })?; + + let mut node = Map::new(); + node.insert("type".to_owned(), types_to_json(kind)); + apply_config_fields(path, &mut node, &Value::Null, config)?; + + Ok(Value::Object(node)) +} + +/// Overlay configuration onto a node the source already declared. +fn node_with_override( + path: &str, + source: &Value, + root: &Value, + config: &ToolParameterConfig, +) -> Result { + let mut node = source.as_object().cloned().unwrap_or_default(); + + if let Some(kind) = &config.kind { + // The source keeps its own declaration; an override may restate it but + // not contradict it, since the source owns the contract. Resolving + // against the document is what lets a referenced type be compared. + let declared = Node::root(root).child(source).types(); + if !declared.is_empty() && !types_match(&declared, kind) { + return Err(Error::InvalidSchema { + path: format!("{path}.type"), + message: format!( + "MCP declares {}, but the configuration declares {}", + format_types(&declared), + format_types(&type_names(kind)) + ), + }); + } + validate_types(path, &type_names(kind))?; + if declared.is_empty() { + node.insert("type".to_owned(), types_to_json(kind)); + } + } + + apply_config_fields(path, &mut node, root, config)?; + + Ok(Value::Object(node)) +} + +/// Apply the override fields shared by both construction paths. +/// +/// `root` is the document nested nodes resolve against; it is [`Value::Null`] +/// when the schema is built from configuration alone. +fn apply_config_fields( + path: &str, + node: &mut Map, + root: &Value, + config: &ToolParameterConfig, +) -> Result<(), Error> { + if let Some(default) = &config.default { + node.insert("default".to_owned(), default.clone()); + } + if let Some(enumeration) = &config.enumeration { + if enumeration.is_empty() { + node.remove("enum"); + } else { + node.insert("enum".to_owned(), Value::Array(enumeration.clone())); + } + } + if let Some(description) = config.summary.as_ref().or(config.description.as_ref()) { + let source = node.get("description").and_then(Value::as_str); + if let Some(merged) = merge_description(Some(description.clone()), source) { + node.insert("description".to_owned(), Value::String(merged)); + } + } + + if let Some(items) = config.items.as_deref() { + let path = format!("{path}.items"); + let merged = match node.get("items") { + Some(source) => node_with_override(&path, source, root, items)?, + None => node_from_config(&path, items)?, + }; + node.insert("items".to_owned(), merged); + } + + if !config.properties.is_empty() { + let mut properties = node + .get("properties") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let mut required = node + .get("required") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + + for (name, property) in config.properties.iter() { + let path = format!("{path}.properties.{name}"); + let merged = match properties.get(name) { + Some(source) => node_with_override(&path, source, root, property)?, + None => node_from_config(&path, property)?, + }; + properties.insert(name.clone(), merged); + + let named = Value::String(name.clone()); + if property.required == Some(true) && !required.contains(&named) { + required.push(named); + } + } + + node.insert("properties".to_owned(), Value::Object(properties)); + if !required.is_empty() { + node.insert("required".to_owned(), Value::Array(required)); + } + } + + Ok(()) +} + +fn type_names(types: &OneOrManyTypes) -> Vec { + match types { + OneOrManyTypes::One(type_) => vec![type_.clone()], + OneOrManyTypes::Many(types) => types.clone(), + } +} + +fn types_to_json(types: &OneOrManyTypes) -> Value { + match types { + OneOrManyTypes::One(type_) => Value::String(type_.clone()), + OneOrManyTypes::Many(types) => { + Value::Array(types.iter().cloned().map(Value::String).collect()) + } + } +} + +/// Whether two type declarations describe the same set of JSON types. +/// +/// JSON Schema type arrays are unordered, and a single-element array means the +/// same thing as a bare string, so `["null", "string"]`, `["string", "null"]` +/// and `"string"` all compare equal. +fn types_match(left: &[String], right: &OneOrManyTypes) -> bool { + let normalize = |mut types: Vec| { + types.sort_unstable(); + types.dedup(); + types + }; + + normalize(left.to_vec()) == normalize(type_names(right)) +} + +#[cfg(test)] +#[path = "json_schema_tests.rs"] +mod tests; diff --git a/crates/jp_mcp/src/server/json_schema_tests.rs b/crates/jp_mcp/src/server/json_schema_tests.rs new file mode 100644 index 000000000..7e1a5e153 --- /dev/null +++ b/crates/jp_mcp/src/server/json_schema_tests.rs @@ -0,0 +1,308 @@ +use indexmap::IndexMap; +use jp_config::conversation::tool::ToolParameterConfig; +use jp_tool::schema::validate; +use serde_json::json; + +use super::*; + +/// Parse a parameter override the way a configuration file would produce it. +fn config(value: serde_json::Value) -> ToolParameterConfig { + serde_json::from_value(value).expect("valid parameter config") +} + +fn configs(values: &[(&str, serde_json::Value)]) -> IndexMap { + values + .iter() + .map(|(name, value)| ((*name).to_owned(), config(value.clone()))) + .collect() +} + +fn error_of(result: Result) -> String { + result.unwrap_err().to_string() +} + +mod from_config { + use super::*; + + #[test] + fn builds_an_object_schema() { + let parameters = configs(&[ + ("path", json!({ "type": "string", "required": true })), + ( + "limit", + json!({ "type": "integer", "default": 10, "summary": "How many." }), + ), + ]); + + let schema = from_config("tools.demo.parameters", ¶meters).unwrap(); + + assert_eq!( + schema, + json!({ + "type": "object", + "properties": { + "path": { "type": "string" }, + "limit": { "type": "integer", "default": 10, "description": "How many." } + }, + "required": ["path"] + }) + ); + } + + #[test] + fn builds_nested_arrays_and_objects() { + let parameters = configs(&[ + ( + "tags", + json!({ "type": "array", "items": { "type": "string", "enum": ["a", "b"] } }), + ), + ( + "target", + json!({ + "type": "object", + "properties": { "path": { "type": "string", "required": true } } + }), + ), + ]); + + let schema = from_config("tools.demo.parameters", ¶meters).unwrap(); + + assert_eq!( + schema, + json!({ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { "type": "string", "enum": ["a", "b"] } + }, + "target": { + "type": "object", + "properties": { "path": { "type": "string" } }, + "required": ["path"] + } + }, + "required": [] + }) + ); + } + + #[test] + fn a_parameter_without_a_type_is_rejected() { + let parameters = configs(&[("path", json!({ "summary": "Where." }))]); + + assert_eq!( + error_of(from_config("tools.demo.parameters", ¶meters)), + "Invalid schema at `tools.demo.parameters.path.type`: local and built-in tool \ + parameters must declare a type" + ); + } +} + +mod with_overrides { + use super::*; + + /// The server's document is the source of truth: anything the override does + /// not speak to survives untouched, `$defs` included. + #[test] + fn preserves_the_server_document() { + let source = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "title": "CreateNote", + "properties": { + "title": { "type": "string" }, + "tags": { "type": "array", "items": { "$ref": "#/$defs/Tag" } } + }, + "required": ["title"], + "$defs": { + "Tag": { "type": "string" } + } + }); + let overrides = configs(&[("tags", json!({ "items": { "enum": ["task", "idea"] } }))]); + + let schema = with_overrides("tools.notes.parameters", &source, &overrides).unwrap(); + + assert_eq!( + schema, + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "title": "CreateNote", + "properties": { + "title": { "type": "string" }, + "tags": { + "type": "array", + "items": { "$ref": "#/$defs/Tag", "enum": ["task", "idea"] } + } + }, + "required": ["title"], + "$defs": { + "Tag": { "type": "string" } + } + }) + ); + } + + /// The reference stays a reference. + /// Narrowing it adds a sibling keyword rather than expanding the definition + /// into the document. + #[test] + fn a_referenced_item_keeps_its_reference() { + let source = json!({ + "type": "object", + "properties": { + "kinds": { "type": "array", "items": { "$ref": "#/$defs/EntryType" } } + }, + "$defs": { "EntryType": { "type": "string", "enum": ["Enum", "Method"] } } + }); + let overrides = configs(&[("kinds", json!({ "items": { "type": "string" } }))]); + + let schema = with_overrides("tools.docs.parameters", &source, &overrides).unwrap(); + + assert_eq!( + schema["properties"]["kinds"]["items"], + json!({ "$ref": "#/$defs/EntryType" }) + ); + } + + #[test] + fn an_empty_enum_clears_an_inherited_one() { + let source = json!({ + "type": "object", + "properties": { "state": { "type": "string", "enum": ["open", "closed"] } } + }); + let overrides = configs(&[("state", json!({ "enum": [] }))]); + + let schema = with_overrides("tools.demo.parameters", &source, &overrides).unwrap(); + + assert_eq!(schema["properties"]["state"], json!({ "type": "string" })); + } + + #[test] + fn a_contradicting_type_is_rejected() { + let source = json!({ + "type": "object", + "properties": { "count": { "type": "integer" } } + }); + let overrides = configs(&[("count", json!({ "type": "string" }))]); + + assert_eq!( + error_of(with_overrides("tools.demo.parameters", &source, &overrides)), + "Invalid schema at `tools.demo.parameters.count.type`: MCP declares integer, but the \ + configuration declares string" + ); + } + + /// A referenced type is compared through the document, so restating it + /// correctly is accepted and restating it wrongly is not. + #[test] + fn a_contradicting_type_is_rejected_through_a_reference() { + let source = json!({ + "type": "object", + "properties": { "kind": { "$ref": "#/$defs/Kind" } }, + "$defs": { "Kind": { "type": "string" } } + }); + let overrides = configs(&[("kind", json!({ "type": "integer" }))]); + + assert_eq!( + error_of(with_overrides("tools.demo.parameters", &source, &overrides)), + "Invalid schema at `tools.demo.parameters.kind.type`: MCP declares string, but the \ + configuration declares integer" + ); + } + + /// JSON Schema type arrays are unordered, and a single-element array means + /// the same as the bare string. + #[test] + fn a_matching_type_may_be_restated_in_any_form() { + let source = json!({ + "type": "object", + "properties": { + "content": { "type": ["string", "null"] }, + "name": { "type": "string" } + } + }); + let overrides = configs(&[ + ("content", json!({ "type": ["null", "string"] })), + ("name", json!({ "type": ["string"] })), + ]); + + let schema = with_overrides("tools.demo.parameters", &source, &overrides).unwrap(); + + assert_eq!( + schema["properties"]["content"]["type"], + json!(["string", "null"]) + ); + assert_eq!(schema["properties"]["name"]["type"], json!("string")); + } + + #[test] + fn required_can_be_tightened_but_not_loosened() { + let source = json!({ + "type": "object", + "properties": { "a": { "type": "string" }, "b": { "type": "string" } }, + "required": ["b"] + }); + let overrides = configs(&[ + ("a", json!({ "required": true })), + ("b", json!({ "required": false })), + ]); + + let schema = with_overrides("tools.demo.parameters", &source, &overrides).unwrap(); + + assert_eq!(schema["required"], json!(["b", "a"])); + } + + /// A property with no `type` is the server saying "any value". + /// The document keeps it as written and the tool stays usable. + #[test] + fn a_free_form_property_survives_and_validates() { + let source = json!({ + "type": "object", + "properties": { + "key": { "type": "string" }, + "value": { "description": "Any JSON value." } + } + }); + + let schema = with_overrides("tools.store.parameters", &source, &IndexMap::new()).unwrap(); + + assert_eq!( + schema["properties"]["value"], + json!({ "description": "Any JSON value." }) + ); + assert!(validate("tools.store.parameters", &schema).is_ok()); + } + + /// Nothing was declared, so nothing is contradicted: configuration may + /// narrow a free-form property to the shape the user actually wants. + #[test] + fn a_free_form_property_can_be_narrowed_by_configuration() { + let source = json!({ + "type": "object", + "properties": { "value": { "description": "Any JSON value." } } + }); + let overrides = configs(&[("value", json!({ "type": "object" }))]); + + let schema = with_overrides("tools.store.parameters", &source, &overrides).unwrap(); + + assert_eq!( + schema["properties"]["value"], + json!({ "type": "object", "description": "Any JSON value." }) + ); + } + + #[test] + fn a_property_the_server_omits_is_added() { + let source = json!({ "type": "object", "properties": {} }); + let overrides = configs(&[("extra", json!({ "type": "string", "summary": "Added." }))]); + + let schema = with_overrides("tools.demo.parameters", &source, &overrides).unwrap(); + + assert_eq!( + schema["properties"]["extra"], + json!({ "type": "string", "description": "Added." }) + ); + } +} diff --git a/crates/jp_mcp/src/server/result.rs b/crates/jp_mcp/src/server/result.rs new file mode 100644 index 000000000..20e4e8a73 --- /dev/null +++ b/crates/jp_mcp/src/server/result.rs @@ -0,0 +1,239 @@ +//! Conversions between ordered tool results and MCP wire content. + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use jp_tool::{ + ContentBlock, ToolResult, + content::{ + Annotations, ErrorDetails, ImageContent, Resource, ResourceContent, ResourceLink, + ToolStatus, + }, +}; +use rmcp::model::{ + AnnotateAble as _, Meta, RawAudioContent, RawContent, RawTextContent, ResourceContents, +}; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::{Error as JsonError, Map, Value}; + +use crate::{CallToolResult, Content}; + +const ERROR_METADATA: &str = "computer.jp/error"; + +/// A result cannot be represented at the MCP boundary. +#[derive(Debug, thiserror::Error)] +pub enum ResultError { + /// Typed protocol metadata is malformed or incompatible. + #[error("Invalid tool result metadata: {0}")] + Metadata(#[from] JsonError), + /// Questions must be answered before a final MCP result is delivered. + #[error("Tool result still requires input")] + UnansweredQuestion, +} + +/// Re-read a value as the type the MCP wire gives it. +/// +/// The wire representation is the only thing both sides are defined against. +/// For rmcp's model types that is not merely convenient but required: +/// `Annotations`, `Icon`, and `IconTheme` are `#[non_exhaustive]`, so nothing +/// outside rmcp can name their fields to build one field by field. +/// +/// An error here means two definitions of the same wire shape have drifted +/// apart, not that a tool sent something malformed. +fn via_wire(value: T) -> Result { + serde_json::from_value(serde_json::to_value(value)?) +} + +/// Decode a native result without projecting away non-text content. +pub fn from_mcp(result: CallToolResult) -> Result { + let metadata = result.meta.map(|meta| meta.0); + let status = match result.is_error { + None => ToolStatus::Unspecified, + Some(false) => ToolStatus::Success, + Some(true) => ToolStatus::Error( + match metadata.as_ref().and_then(|meta| meta.get(ERROR_METADATA)) { + Some(value) => serde_json::from_value(value.clone())?, + None => ErrorDetails::default(), + }, + ), + }; + let content = result + .content + .into_iter() + .map(from_content) + .collect::>()?; + Ok(ToolResult { + content, + status, + structured_content: result.structured_content, + metadata, + }) +} + +fn from_content(content: Content) -> Result { + let annotations: Option = content.annotations.map(via_wire).transpose()?; + Ok(match content.raw { + RawContent::Text(text) => ContentBlock::Text { + text: text.text, + mime_type: None, + annotations, + metadata: text.meta.map(|meta| meta.0), + }, + RawContent::Image(image) => ContentBlock::Image(ImageContent { + data: image.data, + mime_type: image.mime_type, + annotations, + metadata: image.meta.map(|meta| meta.0), + }), + RawContent::Audio(audio) => ContentBlock::Audio { + data: audio.data, + mime_type: audio.mime_type, + annotations, + }, + RawContent::Resource(embedded) => { + let (uri, mime_type, content, content_metadata) = match embedded.resource { + ResourceContents::TextResourceContents { + uri, + mime_type, + text, + meta, + } => (uri, mime_type, ResourceContent::Text(text), meta), + ResourceContents::BlobResourceContents { + uri, + mime_type, + blob, + meta, + } => (uri, mime_type, ResourceContent::EncodedBlob(blob), meta), + }; + ContentBlock::Resource(Resource { + uri, + content, + mime_type, + annotations, + metadata: embedded.meta.map(|meta| meta.0), + content_metadata: content_metadata.map(|meta| meta.0), + name: None, + title: None, + description: None, + formatted: None, + }) + } + RawContent::ResourceLink(link) => { + let mut link: ResourceLink = via_wire(link)?; + link.annotations = annotations; + ContentBlock::ResourceLink(link) + } + }) +} + +/// Encode the final result; an unresolved question is a protocol error. +pub fn to_mcp(result: ToolResult) -> Result { + let ToolResult { + content, + status, + structured_content, + mut metadata, + } = result; + let is_error = match status { + ToolStatus::Unspecified => None, + ToolStatus::Success => Some(false), + ToolStatus::Error(error) => { + if error != ErrorDetails::default() + || metadata + .as_ref() + .is_some_and(|meta| meta.contains_key(ERROR_METADATA)) + { + let metadata = metadata.get_or_insert_with(Map::new); + let mut details: Map = match metadata.remove(ERROR_METADATA) { + Some(value) => serde_json::from_value(value)?, + None => Map::new(), + }; + let encoded: Map = via_wire(error)?; + details.extend(encoded); + metadata.insert(ERROR_METADATA.into(), Value::Object(details)); + } + Some(true) + } + }; + let mut result = CallToolResult::success( + content + .into_iter() + .map(to_content) + .collect::>()?, + ); + result.structured_content = structured_content; + result.is_error = is_error; + result.meta = metadata.map(Meta); + Ok(result) +} + +fn to_content(block: ContentBlock) -> Result { + let (mut content, annotations) = match block { + ContentBlock::Text { + text, + annotations, + metadata, + .. + } => ( + RawContent::Text(RawTextContent { + text, + meta: metadata.map(Meta), + }) + .no_annotation(), + annotations, + ), + ContentBlock::Image(media) => { + let mut content = Content::image(media.data, media.mime_type); + if let RawContent::Image(image) = &mut content.raw { + image.meta = media.metadata.map(Meta); + } + (content, media.annotations) + } + ContentBlock::Audio { + data, + mime_type, + annotations, + } => { + let raw = RawAudioContent { data, mime_type }; + (RawContent::Audio(raw).no_annotation(), annotations) + } + ContentBlock::Resource(resource) => { + let embedded = match resource.content { + ResourceContent::Text(text) => ResourceContents::TextResourceContents { + uri: resource.uri, + mime_type: resource.mime_type, + text, + meta: resource.content_metadata.map(Meta), + }, + ResourceContent::EncodedBlob(blob) => ResourceContents::BlobResourceContents { + uri: resource.uri, + mime_type: resource.mime_type, + blob, + meta: resource.content_metadata.map(Meta), + }, + ResourceContent::Blob(bytes) => ResourceContents::BlobResourceContents { + uri: resource.uri, + mime_type: resource.mime_type, + blob: STANDARD.encode(bytes), + meta: resource.content_metadata.map(Meta), + }, + }; + let mut content = Content::resource(embedded); + if let RawContent::Resource(embedded) = &mut content.raw { + embedded.meta = resource.metadata.map(Meta); + } + (content, resource.annotations) + } + ContentBlock::ResourceLink(mut link) => { + // Annotations live on the enclosing content block, not on the link + // itself, so they are moved out before the link crosses over. + let annotations = link.annotations.take(); + (Content::resource_link(via_wire(link)?), annotations) + } + ContentBlock::Question(_) => return Err(ResultError::UnansweredQuestion), + }; + content.annotations = annotations.map(via_wire).transpose()?; + Ok(content) +} + +#[cfg(test)] +#[path = "result_tests.rs"] +mod tests; diff --git a/crates/jp_mcp/src/server/result_tests.rs b/crates/jp_mcp/src/server/result_tests.rs new file mode 100644 index 000000000..b4f23ac0b --- /dev/null +++ b/crates/jp_mcp/src/server/result_tests.rs @@ -0,0 +1,89 @@ +use jp_tool::{Outcome, Question}; +use serde_json::json; + +use super::*; + +#[test] +fn native_content_round_trips_through_shared_result() { + let wire = json!({ + "content": [ + {"type":"text", "text":"first", "_meta":{"vendor":"text"}, "annotations":{"audience":["assistant"],"priority":0.5}}, + {"type":"image", "data":"aW1hZ2U=", "mimeType":"image/png", "_meta":{"vendor":"image"}}, + {"type":"audio", "data":"YXVkaW8=", "mimeType":"audio/wav"}, + {"type":"resource", "resource":{"uri":"file:///a", "text":"embedded", "mimeType":"text/plain", "_meta":{"inner":true}}, "_meta":{"outer":true}}, + {"type":"resource", "resource":{"uri":"file:///b", "blob":"YmxvYg=="}}, + {"type":"resource_link", "uri":"file:///c", "name":"c", "size":42, "icons":[{"src":"file:///icon", "theme":"dark"}]} + ], + "structuredContent":{"number":42}, + "_meta":{"vendor":{"preserved":true}}, + "isError":false + }); + let native = serde_json::from_value(wire.clone()).unwrap(); + let result = from_mcp(native).unwrap(); + assert_eq!(result.content.len(), 6); + assert!(matches!(result.content[1], ContentBlock::Image(_))); + // Image, audio, and links contribute no text, and a blob resource + // contributes its URI rather than its bytes. + assert_eq!(result.to_text(), "first\n\nembedded\n\nfile:///b"); + assert!(!result.is_error()); + assert_eq!(serde_json::to_value(to_mcp(result).unwrap()).unwrap(), wire); +} + +#[test] +fn error_details_survive_mcp_encoding() { + let result = ToolResult::from(Outcome::Error { + message: "busy".into(), + trace: vec!["upstream".into()], + transient: true, + }); + let native = to_mcp(result.clone()).unwrap(); + assert_eq!( + serde_json::to_value(&native).unwrap(), + json!({ + "content":[{"type":"text","text":"busy\n\nTrace:\nupstream"}], + "isError":true, + "_meta":{"computer.jp/error":{"transient":true,"trace":["upstream"]}} + }) + ); + let decoded = from_mcp(native).unwrap(); + assert_eq!(decoded.status, result.status); + assert!(decoded.is_error()); + assert_eq!(decoded.to_text(), "busy\n\nTrace:\nupstream"); +} + +#[test] +fn unresolved_input_cannot_be_sent_as_final_output() { + let result = ToolResult::from(Outcome::NeedsInput { + question: Question::boolean("confirm", "Proceed?").unwrap(), + }); + assert!(matches!( + to_mcp(result), + Err(ResultError::UnansweredQuestion) + )); +} + +#[test] +fn error_metadata_extensions_and_empty_audience_survive() { + let wire = json!({ + "content":[{"type":"text","text":"failed", "annotations":{"audience":[]}}], + "isError":true, + "_meta":{"computer.jp/error":{"transient":false,"trace":[],"vendorCode":17}} + }); + let result = from_mcp(serde_json::from_value(wire.clone()).unwrap()).unwrap(); + assert_eq!(serde_json::to_value(to_mcp(result).unwrap()).unwrap(), wire); +} + +#[test] +fn malformed_error_metadata_is_rejected() { + let wire = json!({"content":[], "isError":true, "_meta":{"computer.jp/error":{"transient":"yes", "trace":[]}}}); + let error = from_mcp(serde_json::from_value(wire).unwrap()).unwrap_err(); + assert!(error.is_data()); +} + +#[test] +fn omitted_status_is_preserved() { + let wire = json!({"content":[]}); + let result = from_mcp(serde_json::from_value(wire.clone()).unwrap()).unwrap(); + assert_eq!(result.status, ToolStatus::Unspecified); + assert_eq!(serde_json::to_value(to_mcp(result).unwrap()).unwrap(), wire); +} diff --git a/crates/jp_mcp/src/server/service.rs b/crates/jp_mcp/src/server/service.rs new file mode 100644 index 000000000..0ca636fba --- /dev/null +++ b/crates/jp_mcp/src/server/service.rs @@ -0,0 +1,1049 @@ +//! Per-call execution and private MCP Host interactions. +//! +//! [`Service::start_call`] is the execution entry point for the MCP handler. +//! Calls run independently of their result receivers. +//! Dropping a receiver does not cancel or retry work; use [`Call::cancel`] or +//! [`Service::cancel_current`]. +//! The MCP Host must drain [`HostReceiver`] while calls are outstanding. + +use std::{ + collections::HashMap, + error::Error as StdError, + sync::{Arc, Mutex, MutexGuard, PoisonError}, +}; + +use camino::Utf8PathBuf; +use indexmap::IndexMap; +use jp_config::conversation::tool::{ + CommandConfig, FormatMode, ResultMode, RunMode, ToolConfigWithDefaults, ToolSource, + style::ParametersStyle, +}; +use jp_tool::{ + AccessPolicy, Action, ContentBlock, Error as ToolError, InputRequest, QuestionId, + ToolDefinition, ToolResult, + definition::{apply_parameter_defaults, validate_tool_arguments}, + schema::Node, +}; +use serde_json::{Map, Value}; +use tokio::sync::{Notify, broadcast, mpsc, oneshot}; +use tokio_util::sync::CancellationToken; + +use super::{ + Answers, CommandResult, Execution, ExecutionOutcome, InvocationContext, StderrSink, + builtin::BuiltinExecutors, + execute, + result::{ResultError, to_mcp}, + run_tool_command, tool_context, +}; +use crate::{CallToolResult, Client}; + +/// A tool resolved under trusted MCP Host configuration. +#[derive(Clone, Debug)] +pub struct ConfiguredTool { + /// The name and source-neutral argument schema advertised to callers. + pub definition: ToolDefinition, + /// Execution and interaction requirements, including source selection. + pub config: ToolConfigWithDefaults, + /// Compiled access grants supplied by the MCP Host, never by an MCP caller. + /// A compilation failure is delivered as a tool error without execution. + pub access: Result, AccessPolicyError>, + /// Opaque Host-supplied metadata advertised on this tool's MCP description. + /// It does not change execution policy or interpret vendor-specific hints. + pub metadata: Map, +} + +/// An invocation received by the MCP handler. +/// Contains no execution authority. +#[derive(Clone, Debug)] +pub struct CallRequest { + /// The advertised tool name, not the upstream implementation name. + pub name: String, + /// Arguments supplied by the caller. + pub arguments: Map, + /// Opaque caller metadata for Host-side correlation only. + pub correlation: Map, +} + +/// Service-assigned identity, distinct from caller-supplied protocol IDs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct InvocationId(u64); + +/// Identity and original input accompanying each private Host interaction. +#[derive(Clone, Debug)] +pub struct CallInfo { + /// The service-assigned invocation ID. + pub id: InvocationId, + /// Original caller input; edits do not change it. + pub request: CallRequest, +} + +/// A required interaction for one invocation. +/// +/// Replies are single-use and bound to this request. +/// Secret answers and accumulated input are deliberately not exposed through a +/// `Debug` impl. +pub struct HostRequest { + /// Identity and requested arguments for recording/correlation. + pub call: CallInfo, + /// The operation the MCP Host must complete. + pub interaction: Interaction, +} + +/// Receiver held exclusively by the MCP Host. +pub type HostReceiver = mpsc::Receiver; + +/// A Host reply that may fail, for example when recording could not complete. +pub type HostReply = Result; + +/// A failure reported by the MCP Host, without a persisted conversation type. +#[derive(Debug, Clone, thiserror::Error)] +pub enum HostError { + /// The Host could not record the event under its persistence policy. + #[error("MCP Host operation failed: {0}")] + Recording(#[source] Arc), +} + +/// Compilation failed before a call could obtain its access policy. +#[derive(Debug, Clone, thiserror::Error)] +#[error("invalid access policy for tool '{tool}': {source}")] +pub struct AccessPolicyError { + /// The configured tool whose policy failed. + pub tool: String, + /// The original compiler error, retained for diagnostics. + #[source] + pub source: Arc, +} + +/// What a tool's argument formatter produced, or why it produced nothing. +/// +/// A formatter that fails leaves the call runnable: the Host decides whether to +/// show the diagnostic or suppress the call from its display. +pub type Formatted = Result; + +/// An argument formatter failed without producing presentation text. +#[derive(Debug, Clone, thiserror::Error)] +pub enum FormatterError { + /// The command could not execute. + #[error("{0}")] + Execution(#[source] Arc), + /// Formatters cannot invoke an inquiry cycle. + #[error("Custom arguments formatter requested input.")] + InputRequired, + /// The formatter ran and reported a tool error. + #[error("{message}")] + Reported { + /// Formatter diagnostic text, including any tool-supplied trace. + message: String, + }, +} + +/// Whether the Host approved execution, and which edited arguments to use. +#[derive(Debug)] +pub enum Admission { + /// Approved arguments. + /// They are validated again before release. + Run { arguments: Map }, + /// Do not execute. + /// Deliver and record this explanation. + Skip { reason: String }, + /// Resolve a call without execution, preserving an error response if + /// needed. + Complete { result: ToolResult }, +} + +/// The Host may answer a question or resolve the call without another attempt. +#[derive(Debug)] +pub enum InputAnswer { + /// Validated by the service before another execution attempt. + Answer(Value), + /// A declined or cancelled inquiry resolves the logical call. + Complete { result: ToolResult }, +} + +impl From for InputAnswer { + fn from(value: Value) -> Self { + Self::Answer(value) + } +} + +/// The Host releases a prepared call or resolves it without execution. +#[derive(Debug)] +pub enum ReleaseDecision { + /// Begin execution with the approved arguments. + Execute, + /// Preparation failed or the Host stopped the call before execution. + Complete { result: ToolResult }, +} + +/// Host-only services needed by the per-call execution state machine. +pub enum Interaction { + /// Ask whether argument presentation is wanted. + /// This does not authorize an approval-gated formatter to run early. + RenderArguments { + /// True when the MCP Host needs the custom representation. + reply: oneshot::Sender>, + }, + /// Apply admission policy and argument editing. + /// A successful reply also acknowledges recording/preparation of the + /// request. + Prepare { + /// Resolved interaction requirements, including formatter policy. + config: Box, + /// Coerced/defaulted arguments presented for approval. + arguments: Map, + /// Custom formatter output, if formatting was permitted before + /// approval. + formatted_arguments: Option, + /// One reply for this preparation operation. + reply: oneshot::Sender>, + }, + /// Wait for the Host's execution phase and recording barrier. + Release { + /// Validated arguments that will actually execute. + arguments: Map, + /// Custom representation of the approved arguments, if requested. + formatted_arguments: Option, + /// Permission to execute, or a final response without execution. + reply: oneshot::Sender>, + }, + /// Obtain and record input before the next execution attempt. + Input { + /// The expected answer shape and secrecy constraints. + request: InputRequest, + /// Context shown with the input request. + supporting: Vec, + /// Accumulated answers. + /// These may contain secrets and must not be logged. + answers: IndexMap, + /// The answer, after Host routing and recording/redaction. + reply: oneshot::Sender>, + }, + /// Review/edit a completed result under the configured delivery policy. + Review { + /// The required delivery interaction. + mode: ResultMode, + /// Unedited execution result. + result: ToolResult, + /// The content approved for delivery, including skip explanations. + reply: oneshot::Sender>, + }, + /// Acknowledge final recording before returning the result to the caller. + Record { + /// What the Host is being asked to record. + /// + /// Boxed because it is the largest thing the private channel carries, + /// and every other interaction in flight would otherwise be sized for + /// it. + recording: Box, + /// Acknowledges the Host's configured persistence policy, not an + /// unconditional disk write. + reply: oneshot::Sender>, + }, +} + +/// One call as the Host should record it. +#[derive(Debug)] +pub struct Recording { + /// Post-edit execution arguments, separate from `CallInfo::request`. + pub arguments: Map, + + /// Original completed result; absent for skipped calls. + pub raw_result: Option, + + /// Content approved for delivery. + pub result: ToolResult, +} + +impl Interaction { + /// Whether the server has abandoned this interaction, for example after a + /// restart. + #[must_use] + pub fn is_expired(&self) -> bool { + match self { + Self::RenderArguments { reply } => reply.is_closed(), + Self::Prepare { reply, .. } => reply.is_closed(), + Self::Release { reply, .. } => reply.is_closed(), + Self::Input { reply, .. } => reply.is_closed(), + Self::Review { reply, .. } => reply.is_closed(), + Self::Record { reply, .. } => reply.is_closed(), + } + } +} + +/// Bounded, best-effort progress. +/// It is independent of required Host requests. +#[derive(Clone, Debug)] +pub struct Progress { + /// Invocation emitting the line. + pub id: InvocationId, + /// A tool stderr line, without its newline terminator. + pub line: String, +} + +/// Failure of the service protocol or execution infrastructure. +#[derive(Debug, thiserror::Error)] +pub enum ServiceError { + /// The service no longer admits calls. + #[error("JP MCP Server is stopped")] + Stopped, + /// Work was explicitly cancelled before delivery. + #[error("Tool invocation cancelled")] + Cancelled, + /// The required Host interaction connection was lost. + #[error("MCP Host disconnected before completing the interaction")] + HostDisconnected, + /// The Host declined an operation, including failed recording. + #[error(transparent)] + Host(#[from] HostError), + /// Access policy compilation failed before formatting or execution. + #[error(transparent)] + Access(#[from] AccessPolicyError), + /// A final result cannot be represented by the MCP transport. + #[error(transparent)] + Result(#[from] ResultError), + /// Tool lookup, validation, or execution failed. + #[error(transparent)] + Tool(#[from] ToolError), + /// The Host returned data outside the tool's requested answer shape. + #[error("Invalid answer for tool question `{0}`")] + InvalidAnswer(QuestionId), + /// An argument violates the schema's type or enumeration. + #[error("Invalid tool argument at `{path}`: value violates its type or enum")] + InvalidArgument { path: String }, + /// A configured name cannot select multiple tool implementations. + #[error("Duplicate tool configured: {0}")] + DuplicateTool(String), + /// Restricted configuration must have a compiled policy. + #[error("Missing compiled access policy for tool `{0}`")] + MissingAccessPolicy(String), + /// IDs must not wrap and alias an earlier invocation. + #[error("Tool invocation identifiers exhausted")] + IdExhausted, + /// An execution task failed without producing a result. + #[error("Tool execution task ended without a result")] + TaskLost, +} + +/// Handle to a submitted call. +/// Dropping it leaves execution running. +#[derive(Debug)] +pub struct Call { + id: InvocationId, + cancellation: CancellationToken, + result: oneshot::Receiver>, +} + +impl Call { + /// Service identity to correlate with Host interactions. + #[must_use] + pub fn id(&self) -> InvocationId { + self.id + } + + /// Whether the final result or task failure is ready to receive. + #[must_use] + pub fn is_finished(&self) -> bool { + !self.result.is_empty() || self.result.is_terminated() + } + + pub(super) fn cancellation_token(&self) -> CancellationToken { + self.cancellation.clone() + } + + /// Cancel this invocation, including a pending Host interaction. + pub fn cancel(&self) { + self.cancellation.cancel(); + } + + /// Wait for execution and the final Host recording acknowledgement. + pub async fn finish(self) -> Result { + self.result + .await + .map_err(|_| ServiceError::TaskLost)? + .map(|output| output.result) + } +} + +#[derive(Debug)] +struct CallOutput { + result: ToolResult, + delivery_decided: bool, +} + +impl Call { + /// Receive the complete MCP result, retaining unedited upstream content. + pub async fn finish_mcp(self) -> Result { + let output = self.result.await.map_err(|_| ServiceError::TaskLost)??; + Ok(to_mcp(output.result)?) + } +} + +/// In-process tool service with immutable Host-bound execution context. +/// +/// The upstream client must be owned by this service: shutdown closes its +/// services, including connections visible through any clones of that client. +/// Dropping this owner signals cancellation; [`shutdown`] additionally waits +/// for cleanup. +/// +/// [`shutdown`]: Self::shutdown +pub struct Service { + inner: Arc, +} + +struct Inner { + tools: IndexMap, + upstream: Client, + builtins: BuiltinExecutors, + root: Utf8PathBuf, + invocation: InvocationContext, + host: mpsc::Sender, + progress: broadcast::Sender, + state: Mutex, + idle: Notify, +} + +#[derive(Default)] +struct State { + stopped: bool, + next_id: u64, + active: HashMap, +} + +struct CallControl { + lifetime: CancellationToken, + attempt: CancellationToken, + resume: Arc, + + /// The result the Host resolved this call with, delivered in place of + /// another attempt when the paused call wakes. + completion: Option, +} + +impl Inner { + fn state(&self) -> MutexGuard<'_, State> { + // No caller code runs under this lock. Recovering it permits cleanup + // after an unrelated panic rather than orphaning active calls. + self.state.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +struct ActiveCall { + inner: Arc, + id: InvocationId, +} + +impl Drop for ActiveCall { + fn drop(&mut self) { + self.inner.state().active.remove(&self.id); + self.inner.idle.notify_waiters(); + } +} + +impl Service { + /// Bind resolved tools, access policies, and working context to the + /// service. + /// + /// No tool code is run. + /// The returned receiver is the private Host interface. + pub fn new( + tools: Vec, + upstream: Client, + builtins: BuiltinExecutors, + root: Utf8PathBuf, + invocation: InvocationContext, + ) -> Result<(Self, HostReceiver), ServiceError> { + let mut catalog = IndexMap::new(); + for tool in tools { + if tool.config.access().is_some() && matches!(tool.access, Ok(None)) { + return Err(ServiceError::MissingAccessPolicy(tool.definition.name)); + } + let name = tool.definition.name.clone(); + if catalog.insert(name.clone(), tool).is_some() { + return Err(ServiceError::DuplicateTool(name)); + } + } + let (host, receiver) = mpsc::channel(32); + let (progress, _) = broadcast::channel(64); + Ok(( + Self { + inner: Arc::new(Inner { + tools: catalog, + upstream, + builtins, + root, + invocation, + host, + progress, + state: Mutex::new(State::default()), + idle: Notify::new(), + }), + }, + receiver, + )) + } + + /// Advertised definitions in their configured order. + pub fn definitions(&self) -> impl Iterator { + self.inner.tools.values().map(|tool| &tool.definition) + } + + /// Metadata supplied by the Host; empty maps are omitted from descriptions. + pub(super) fn tool_metadata(&self, name: &str) -> Option<&Map> { + self.inner + .tools + .get(name) + .map(|tool| &tool.metadata) + .filter(|meta| !meta.is_empty()) + } + + /// Subscribe to stderr progress without slowing execution or Host replies. + /// A lagging subscriber receives the broadcast channel's lag error. + #[must_use] + pub fn subscribe_progress(&self) -> broadcast::Receiver { + self.inner.progress.subscribe() + } + + /// Submit work from the MCP handler and allocate an independent invocation + /// ID. + /// + /// The caller must be inside a Tokio runtime. + /// Caller metadata is forwarded only for correlation; it never sets the + /// root, policy, or answers. + pub fn start_call(&self, request: CallRequest) -> Result { + let inner = self.inner.clone(); + let mut state = inner.state(); + if state.stopped { + return Err(ServiceError::Stopped); + } + let tool = inner + .tools + .get(&request.name) + .cloned() + .ok_or_else(|| ToolError::NotFound { + name: request.name.clone(), + })?; + state.next_id = state + .next_id + .checked_add(1) + .ok_or(ServiceError::IdExhausted)?; + let id = InvocationId(state.next_id); + let cancellation = CancellationToken::new(); + let mut attempt = cancellation.child_token(); + let resume = Arc::new(Notify::new()); + state.active.insert(id, CallControl { + lifetime: cancellation.clone(), + attempt: attempt.clone(), + resume: resume.clone(), + completion: None, + }); + drop(state); + let (sender, result) = oneshot::channel(); + let task_token = cancellation.clone(); + let active = ActiveCall { + inner: inner.clone(), + id, + }; + tokio::spawn(async move { + let _active = active; + let call = CallInfo { id, request }; + let result = loop { + let result = tokio::select! { + biased; + () = task_token.cancelled() => Some(Err(ServiceError::Cancelled)), + () = inner.host.closed() => Some(Err(ServiceError::HostDisconnected)), + () = attempt.cancelled() => None, + result = run_call(&inner, &call, tool.clone(), &attempt) => Some(result), + }; + if let Some(result) = result { + break result; + } + // The MCP caller still owns the same pending request. Wait for + // the Host to re-prepare it before opening another attempt. + tokio::select! { + biased; + () = task_token.cancelled() => break Err(ServiceError::Cancelled), + () = inner.host.closed() => break Err(ServiceError::HostDisconnected), + () = resume.notified() => {}, + } + let completion = inner + .state() + .active + .get_mut(&id) + .and_then(|control| control.completion.take()); + if let Some(result) = completion { + break Ok(CallOutput { + result, + delivery_decided: true, + }); + } + attempt = task_token.child_token(); + if let Some(control) = inner.state().active.get_mut(&id) { + control.attempt = attempt.clone(); + } + }; + drop(sender.send(result)); + }); + Ok(Call { + id, + cancellation, + result, + }) + } + + /// Observe cancellation of an invocation through the private Host channel. + /// Returns `None` after the invocation has left the active set. + #[must_use] + pub fn call_cancellation(&self, id: InvocationId) -> Option { + self.inner + .state() + .active + .get(&id) + .map(|control| control.lifetime.clone()) + } + + /// Stop the current attempt without completing the MCP call. + /// The Host must call `resume_call` to re-prepare and release another + /// attempt. + #[must_use] + pub fn pause_call(&self, id: InvocationId) -> bool { + let state = self.inner.state(); + let Some(control) = state.active.get(&id) else { + return false; + }; + control.attempt.cancel(); + true + } + + /// Allow a paused call to start another preparation/approval cycle. + pub fn resume_call(&self, id: InvocationId) { + if let Some(control) = self.inner.state().active.get(&id) { + control.resume.notify_one(); + } + } + + /// Resolve an invocation with `result` in place of its current attempt. + /// + /// The attempt stops, and `result` is what the MCP caller receives. + /// No review or recording interaction follows: the Host supplies a result + /// it has already recorded. + /// + /// Returns `false` when the invocation has left the active set, in which + /// case its caller already has a result. + #[must_use] + pub fn complete_call(&self, id: InvocationId, result: ToolResult) -> bool { + let mut state = self.inner.state(); + let Some(control) = state.active.get_mut(&id) else { + return false; + }; + control.completion = Some(result); + control.attempt.cancel(); + control.resume.notify_one(); + true + } + + /// Cancel an invocation identified through the private Host channel. + pub fn cancel_call(&self, id: InvocationId) { + if let Some(token) = self.inner.state().active.get(&id) { + token.lifetime.cancel(); + } + } + + /// Stop current calls without preventing admission of later work. + pub fn cancel_current(&self) { + for token in self.inner.state().active.values() { + token.lifetime.cancel(); + } + } + + /// Stop admission and signal cancellation without waiting for cleanup. + pub fn stop(&self) { + let mut state = self.inner.state(); + state.stopped = true; + for token in state.active.values() { + token.lifetime.cancel(); + } + } + + /// Stop admission, cancel outstanding calls, wait for their cleanup, and + /// close owned upstream services. + /// Safe to call more than once. + pub async fn shutdown(&self) { + self.stop(); + loop { + // Enabling the notification before checking is what makes this + // race-free: a call finishing in between is still observed. + let idle = self.inner.idle.notified(); + tokio::pin!(idle); + idle.as_mut().enable(); + if self.inner.state().active.is_empty() { + break; + } + idle.await; + } + self.inner.upstream.shutdown().await; + } +} + +impl Drop for Service { + fn drop(&mut self) { + self.stop(); + } +} + +async fn ask( + inner: &Inner, + call: &CallInfo, + interaction: impl FnOnce(oneshot::Sender>) -> Interaction, +) -> Result { + let (reply, receiver) = oneshot::channel(); + inner + .host + .send(HostRequest { + call: call.clone(), + interaction: interaction(reply), + }) + .await + .map_err(|_| ServiceError::HostDisconnected)?; + receiver + .await + .map_err(|_| ServiceError::HostDisconnected)? + .map_err(Into::into) +} + +fn validate_arguments( + tool: &ConfiguredTool, + arguments: &mut Map, +) -> Result<(), ServiceError> { + tool.definition.coerce_arguments(arguments); + apply_parameter_defaults(arguments, &tool.definition.parameters); + validate_tool_arguments(arguments, &tool.definition.parameters)?; + for (name, node) in Node::root(&tool.definition.parameters).properties() { + if let Some(value) = arguments.get(&name) { + validate_value(&name, value, &node)?; + } + } + Ok(()) +} + +fn validate_value(path: &str, value: &Value, node: &Node<'_>) -> Result<(), ServiceError> { + if !node.permits(value) { + return Err(ServiceError::InvalidArgument { path: path.into() }); + } + if let Some(object) = value.as_object() { + for (name, child) in node.properties() { + if let Some(value) = object.get(&name) { + validate_value(&format!("{path}.{name}"), value, &child)?; + } + } + } + if let (Some(values), Some(items)) = (value.as_array(), node.items()) { + for (index, value) in values.iter().enumerate() { + validate_value(&format!("{path}[{index}]"), value, &items)?; + } + } + Ok(()) +} + +async fn run_call( + inner: &Inner, + call: &CallInfo, + tool: ConfiguredTool, + cancellation: &CancellationToken, +) -> Result { + let mut arguments = call.request.arguments.clone(); + validate_arguments(&tool, &mut arguments)?; + // A skipped or hidden call shows nothing, so its formatter is a command + // that would run for output nobody reads. + let formatter = match &tool.config.style().parameters { + ParametersStyle::Custom(command) + if tool.config.run() != RunMode::Skip && !tool.config.style().hidden => + { + Some(command.clone().command()) + } + _ => None, + }; + let formatter = match formatter { + Some(command) + if ask(inner, call, |reply| Interaction::RenderArguments { reply }).await? => + { + Some(command) + } + _ => None, + }; + // `format = "ask"` holds a user-configured command back until the Host has + // admitted the call. + let mut formatted_arguments = match &formatter { + Some(command) if tool.config.format() == FormatMode::Unattended => { + Some(format_arguments(inner, &tool, command, &arguments, cancellation).await?) + } + _ => None, + }; + let original_arguments = arguments.clone(); + // `run = "skip"` is the service's own decision, so it needs no Host + // admission, but it resolves the call the same way a Host denial does. + let admission = if tool.config.run() == RunMode::Skip { + Admission::Skip { + reason: "Tool execution skipped by configuration.".into(), + } + } else { + ask(inner, call, |reply| Interaction::Prepare { + config: Box::new(tool.config.clone()), + arguments: arguments.clone(), + formatted_arguments: formatted_arguments.clone(), + reply, + }) + .await? + }; + arguments = match admission { + Admission::Run { arguments } => arguments, + Admission::Skip { reason } => { + return record_without_executing(inner, call, arguments, ToolResult::text(reason)) + .await; + } + Admission::Complete { result } => { + return record_without_executing(inner, call, arguments, result).await; + } + }; + validate_arguments(&tool, &mut arguments)?; + // Arguments the Host edited make any earlier formatting stale, so the + // presentation is rebuilt from what will actually execute. + if let Some(command) = &formatter + && (formatted_arguments.is_none() || arguments != original_arguments) + { + formatted_arguments = + Some(format_arguments(inner, &tool, command, &arguments, cancellation).await?); + } + let release = ask(inner, call, |reply| Interaction::Release { + arguments: arguments.clone(), + formatted_arguments, + reply, + }) + .await?; + let (output, executed) = match release { + ReleaseDecision::Execute => ( + execute_with_answers(inner, call, &tool, &arguments, cancellation).await?, + true, + ), + ReleaseDecision::Complete { result } => ( + CallOutput { + result, + delivery_decided: true, + }, + false, + ), + }; + deliver_result(inner, call, &tool, arguments, output, executed).await +} + +/// Record a call the Host resolved before it could execute. +async fn record_without_executing( + inner: &Inner, + call: &CallInfo, + arguments: Map, + result: ToolResult, +) -> Result { + ask(inner, call, |reply| Interaction::Record { + recording: Box::new(Recording { + arguments, + // Nothing ran, so there is no unedited result behind the delivered + // one. + raw_result: None, + result: result.clone(), + }), + reply, + }) + .await?; + Ok(CallOutput { + result, + delivery_decided: true, + }) +} + +async fn deliver_result( + inner: &Inner, + call: &CallInfo, + tool: &ConfiguredTool, + arguments: Map, + output: CallOutput, + executed: bool, +) -> Result { + let CallOutput { + result: raw_result, + delivery_decided, + } = output; + let result = if delivery_decided { + raw_result.clone() + } else { + match tool.config.result() { + ResultMode::Skip => ToolResult::text("Result delivery skipped by configuration."), + ResultMode::Unattended => raw_result.clone(), + mode @ (ResultMode::Ask | ResultMode::Edit) => { + ask(inner, call, |reply| Interaction::Review { + mode, + result: raw_result.clone(), + reply, + }) + .await? + } + } + }; + ask(inner, call, |reply| Interaction::Record { + recording: Box::new(Recording { + arguments, + // A call the Host resolved at an earlier barrier never produced a + // result of its own, so there is nothing unedited behind it. + raw_result: (executed && !delivery_decided).then_some(raw_result), + result: result.clone(), + }), + reply, + }) + .await?; + Ok(CallOutput { + result, + delivery_decided: true, + }) +} + +async fn execute_with_answers( + inner: &Inner, + call: &CallInfo, + tool: &ConfiguredTool, + arguments: &Map, + cancellation: &CancellationToken, +) -> Result { + let access = match &tool.access { + Ok(access) => access.as_ref(), + Err(error) => { + return Ok(CallOutput { + result: ToolResult::error(error.to_string()), + delivery_decided: false, + }); + } + }; + let progress = inner.progress.clone(); + let id = call.id; + let stderr: StderrSink = Arc::new(move |line: &str| { + drop(progress.send(Progress { + id, + line: line.into(), + })); + }); + // Built once: every attempt of this invocation runs the same tool, in the + // same place, under the same policy. Only the answers grow. + let execution = Execution { + definition: &tool.definition, + id: call.id.0.to_string(), + arguments: Value::Object(arguments.clone()), + config: &tool.config, + root: &inner.root, + access, + invocation: &inner.invocation, + builtins: &inner.builtins, + upstream: &inner.upstream, + cancellation: cancellation.clone(), + stderr: Some(stderr), + }; + let mut answers = Answers::new(); + loop { + match execute(&execution, &answers).await? { + ExecutionOutcome::Cancelled { .. } => return Err(ServiceError::Cancelled), + ExecutionOutcome::Completed { result, .. } => { + return Ok(CallOutput { + result, + delivery_decided: false, + }); + } + ExecutionOutcome::NeedsInput { mut question, .. } => { + let supporting = question + .pre_amble + .take() + .into_iter() + .map(ContentBlock::text) + .collect(); + let request = InputRequest::from(question); + let answer = ask(inner, call, |reply| Interaction::Input { + request: request.clone(), + supporting, + answers: answers.clone(), + reply, + }) + .await?; + let answer = match answer { + InputAnswer::Answer(answer) => answer, + InputAnswer::Complete { result } => { + return Ok(CallOutput { + result, + delivery_decided: true, + }); + } + }; + if !Node::root(&Value::Object(request.schema())).permits(&answer) { + return Err(ServiceError::InvalidAnswer(request.id.clone())); + } + answers.insert(request.id.to_string(), answer); + } + } + } +} + +/// Run a tool's configured argument formatter and return what it printed. +/// +/// A formatter that fails is presentation that failed, not a failed call, so it +/// comes back as [`FormatterError`] for the Host to show or suppress. +/// Only cancellation and a policy that never compiled end the call itself. +async fn format_arguments( + inner: &Inner, + tool: &ConfiguredTool, + command: &CommandConfig, + arguments: &Map, + cancellation: &CancellationToken, +) -> Result { + let name = match tool.config.source() { + ToolSource::Local { tool: name } + | ToolSource::Builtin { tool: name } + | ToolSource::Mcp { tool: name, .. } => name.as_deref().unwrap_or(&tool.definition.name), + }; + let context = tool_context( + name, + &Value::Object(arguments.clone()), + &IndexMap::new(), + &tool.config, + &inner.root, + &Action::FormatArguments, + tool.access.as_ref().map_err(Clone::clone)?.as_ref(), + &inner.invocation, + ); + let result = match run_tool_command( + command.clone(), + context, + &inner.root, + cancellation.clone(), + None, + ) + .await + { + Ok(result) => result, + Err(error) => return Ok(Err(FormatterError::Execution(Arc::new(error)))), + }; + match result { + CommandResult::NeedsInput(_) => Ok(Err(FormatterError::InputRequired)), + CommandResult::Cancelled => Err(ServiceError::Cancelled), + CommandResult::Success(text) => Ok(Ok(text.trim().into())), + CommandResult::TransientError { message, trace } => Ok(Err(FormatterError::Reported { + message: CommandResult::format_error(&message, &trace), + })), + other => { + let result = other.into_tool_result(name); + let message = result.to_text(); + if result.is_error() { + Ok(Err(FormatterError::Reported { message })) + } else { + Ok(Ok(message.trim().into())) + } + } + } +} + +#[cfg(test)] +#[path = "service_tests.rs"] +mod tests; diff --git a/crates/jp_mcp/src/server/service_tests.rs b/crates/jp_mcp/src/server/service_tests.rs new file mode 100644 index 000000000..347486d95 --- /dev/null +++ b/crates/jp_mcp/src/server/service_tests.rs @@ -0,0 +1,860 @@ +#[cfg(unix)] +use std::fs; +use std::{ + future::pending, + io, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, +}; + +use assert_matches::assert_matches; +use async_trait::async_trait; +use camino::Utf8Path; +#[cfg(unix)] +use camino_tempfile::{Utf8TempDir, tempdir}; +use jp_config::{ + AppConfig, Config as _, + conversation::tool::{PartialToolConfig, ToolConfig}, +}; +use jp_tool::{Outcome, Question, ToolDefinition, ToolDocs}; +use serde_json::{Value, json}; +use tokio::{ + sync::{Notify, mpsc::error::TryRecvError}, + time::{Duration, timeout}, +}; + +use super::*; +use crate::server::builtin::BuiltinTool; + +struct CountingTool(Arc); + +#[async_trait] +impl BuiltinTool for CountingTool { + async fn execute(&self, arguments: &Value, answers: &IndexMap) -> Outcome { + self.0.fetch_add(1, Ordering::SeqCst); + match answers.get("confirm") { + Some(answer) => Outcome::Success { + content: json!({"arguments": arguments, "answer": answer}).to_string(), + }, + None => Question::boolean("confirm", "Proceed?") + .unwrap() + .with_preamble("Review this operation.") + .into(), + } + } +} + +/// Build a service around one builtin tool named `count`. +/// +/// `config` is the tool's configuration as a user would write it, so a test +/// says what it needs rather than patching a service after construction. +fn service( + config: Value, + root: &Utf8Path, + builtins: BuiltinExecutors, + invocation: InvocationContext, +) -> (Service, HostReceiver) { + let partial: PartialToolConfig = serde_json::from_value(config).unwrap(); + let mut app = AppConfig::new_test(); + app.conversation.tools.insert( + "count".into(), + ToolConfig::from_partial(partial, vec![]).unwrap(), + ); + let tool = ConfiguredTool { + definition: ToolDefinition { + name: "count".into(), + docs: ToolDocs::default(), + parameters: json!({ + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"], + }), + }, + config: app.conversation.tools.get("count").unwrap(), + access: Ok(None), + metadata: Map::new(), + }; + Service::new( + vec![tool], + Client::default(), + builtins, + root.to_owned(), + invocation, + ) + .unwrap() +} + +/// A service whose `count` tool asks one question and then echoes its input. +/// +/// `run` and `result` are that tool's `run` and `result` settings, spelled as a +/// user writes them: `unattended`, `ask`, `edit`, or `skip`. +/// +/// The counter records how many execution attempts actually ran, which is what +/// separates "the call was denied" from "the call silently went nowhere". +fn fixture(run: &str, result: &str) -> (Service, HostReceiver, Arc) { + let count = Arc::new(AtomicUsize::new(0)); + let (service, host) = service( + json!({"source": "builtin", "run": run, "result": result}), + "/tmp".into(), + BuiltinExecutors::new().register("count", CountingTool(count.clone())), + InvocationContext::default(), + ); + (service, host, count) +} + +async fn release(host: &mut HostReceiver) { + let Interaction::Prepare { + arguments, reply, .. + } = next(host).await.interaction + else { + panic!("expected preparation") + }; + reply.send(Ok(Admission::Run { arguments })).unwrap(); + let Interaction::Release { reply, .. } = next(host).await.interaction else { + panic!("expected release") + }; + reply.send(Ok(ReleaseDecision::Execute)).unwrap(); +} + +async fn next(host: &mut HostReceiver) -> HostRequest { + timeout(Duration::from_secs(2), host.recv()) + .await + .unwrap() + .unwrap() +} + +fn request() -> CallRequest { + CallRequest { + name: "count".into(), + arguments: json!({"path":"original"}).as_object().unwrap().clone(), + correlation: Map::new(), + } +} + +#[tokio::test] +async fn preparation_release_input_and_delivery_use_distinct_acknowledgements() { + let (service, mut host, count) = fixture("edit", "edit"); + let call = service.start_call(request()).unwrap(); + let id = call.id(); + let prepared = next(&mut host).await; + assert_eq!(prepared.call.id, id); + let Interaction::Prepare { + arguments, reply, .. + } = prepared.interaction + else { + panic!("expected preparation") + }; + assert_eq!(arguments, request().arguments); + assert_eq!(count.load(Ordering::SeqCst), 0); + reply + .send(Ok(Admission::Run { + arguments: json!({"path":"edited"}).as_object().unwrap().clone(), + })) + .unwrap(); + let Interaction::Release { + arguments, reply, .. + } = next(&mut host).await.interaction + else { + panic!("expected release") + }; + assert_eq!(arguments["path"], "edited"); + assert_eq!(count.load(Ordering::SeqCst), 0); + reply.send(Ok(ReleaseDecision::Execute)).unwrap(); + let Interaction::Input { + request, + supporting, + answers, + reply, + } = next(&mut host).await.interaction + else { + panic!("expected input") + }; + assert_eq!(request.id.as_str(), "confirm"); + assert_eq!(supporting, vec![ContentBlock::text( + "Review this operation." + )]); + assert!(answers.is_empty()); + assert_eq!(count.load(Ordering::SeqCst), 1); + reply.send(Ok(json!(true).into())).unwrap(); + let Interaction::Review { result, reply, .. } = next(&mut host).await.interaction else { + panic!("expected review") + }; + assert_eq!( + result, + ToolResult::text(r#"{"arguments":{"path":"edited"},"answer":true}"#) + ); + assert_eq!(count.load(Ordering::SeqCst), 2); + reply.send(Ok(ToolResult::text("edited result"))).unwrap(); + let Interaction::Record { recording, reply } = next(&mut host).await.interaction else { + panic!("expected recording") + }; + assert_eq!(recording.result, ToolResult::text("edited result")); + assert!(!call.is_finished()); + reply.send(Ok(())).unwrap(); + assert_eq!( + call.finish().await.unwrap(), + ToolResult::text("edited result") + ); + service.shutdown().await; +} + +#[tokio::test] +async fn restart_keeps_the_logical_call_open_and_replaces_old_replies() { + let (service, mut host, count) = fixture("ask", "unattended"); + let call = service.start_call(request()).unwrap(); + let id = call.id(); + release(&mut host).await; + let old = next(&mut host).await; + assert_eq!(count.load(Ordering::SeqCst), 1); + assert!(service.pause_call(id)); + service.resume_call(id); + release(&mut host).await; + let Interaction::Input { reply, .. } = next(&mut host).await.interaction else { + panic!("expected input") + }; + assert!(old.interaction.is_expired()); + assert_eq!(count.load(Ordering::SeqCst), 2); + assert!(!call.is_finished()); + reply.send(Ok(InputAnswer::Answer(json!(true)))).unwrap(); + let recorded = next(&mut host).await; + assert_eq!(recorded.call.id, id); + let Interaction::Record { reply, .. } = recorded.interaction else { + panic!("expected recording") + }; + reply.send(Ok(())).unwrap(); + assert_eq!( + call.finish().await.unwrap(), + ToolResult::text(r#"{"arguments":{"path":"original"},"answer":true}"#) + ); + assert_eq!(count.load(Ordering::SeqCst), 3); + service.shutdown().await; +} + +#[tokio::test] +async fn denied_call_never_executes() { + let (service, mut host, count) = fixture("ask", "unattended"); + let call = service.start_call(request()).unwrap(); + let Interaction::Prepare { reply, .. } = next(&mut host).await.interaction else { + panic!("expected preparation") + }; + reply + .send(Ok(Admission::Skip { + reason: "denied".into(), + })) + .unwrap(); + let Interaction::Record { recording, reply } = next(&mut host).await.interaction else { + panic!("expected recording") + }; + assert_eq!(recording.result, ToolResult::text("denied")); + reply.send(Ok(())).unwrap(); + assert_eq!(call.finish().await.unwrap(), ToolResult::text("denied")); + assert_eq!(count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn invalid_edited_arguments_do_not_reach_execution() { + let (service, mut host, count) = fixture("edit", "unattended"); + let call = service.start_call(request()).unwrap(); + let Interaction::Prepare { reply, .. } = next(&mut host).await.interaction else { + panic!("expected preparation") + }; + reply + .send(Ok(Admission::Run { + arguments: Map::new(), + })) + .unwrap(); + assert!(matches!( + call.finish().await, + Err(ServiceError::Tool(ToolError::Arguments { .. })) + )); + assert_eq!(count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn host_loss_fails_closed() { + let (service, host, count) = fixture("ask", "unattended"); + drop(host); + let call = service.start_call(request()).unwrap(); + assert!(matches!( + call.finish().await, + Err(ServiceError::HostDisconnected) + )); + assert_eq!(count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn shutdown_cancels_pending_release_and_rejects_late_reply() { + let (service, mut host, count) = fixture("ask", "unattended"); + let call = service.start_call(request()).unwrap(); + let Interaction::Prepare { reply, .. } = next(&mut host).await.interaction else { + panic!("expected preparation") + }; + reply + .send(Ok(Admission::Run { + arguments: request().arguments, + })) + .unwrap(); + let Interaction::Release { reply, .. } = next(&mut host).await.interaction else { + panic!("expected release") + }; + timeout(Duration::from_secs(2), service.shutdown()) + .await + .unwrap(); + assert!(reply.send(Ok(ReleaseDecision::Execute)).is_err()); + assert!(matches!(call.finish().await, Err(ServiceError::Cancelled))); + assert!(matches!( + service.start_call(request()), + Err(ServiceError::Stopped) + )); + assert_eq!(count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn invalid_answer_prevents_a_second_attempt() { + let (service, mut host, count) = fixture("ask", "unattended"); + let call = service.start_call(request()).unwrap(); + release(&mut host).await; + let Interaction::Input { reply, .. } = next(&mut host).await.interaction else { + panic!("expected input") + }; + assert_eq!(count.load(Ordering::SeqCst), 1); + reply.send(Ok(json!("not a boolean").into())).unwrap(); + assert!(matches!(call.finish().await, Err(ServiceError::InvalidAnswer(id)) if id == "confirm")); + assert_eq!(count.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn failed_recording_prevents_result_delivery() { + let (service, mut host, count) = fixture("ask", "unattended"); + let call = service.start_call(request()).unwrap(); + release(&mut host).await; + let Interaction::Input { reply, .. } = next(&mut host).await.interaction else { + panic!("expected input") + }; + reply.send(Ok(json!(true).into())).unwrap(); + let Interaction::Record { reply, .. } = next(&mut host).await.interaction else { + panic!("expected recording") + }; + reply + .send(Err(HostError::Recording(Arc::new(io::Error::other( + "disk full", + ))))) + .unwrap(); + assert_matches!( + call.finish().await, + Err(ServiceError::Host(HostError::Recording(source))) + if source.to_string() == "disk full" + ); + assert_eq!(count.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn current_call_cancellation_does_not_poison_later_calls() { + let (service, mut host, count) = fixture("ask", "unattended"); + let first = service.start_call(request()).unwrap(); + release(&mut host).await; + let Interaction::Input { reply: stale, .. } = next(&mut host).await.interaction else { + panic!("expected input") + }; + service.cancel_current(); + assert!(matches!(first.finish().await, Err(ServiceError::Cancelled))); + assert!(stale.send(Ok(json!(true).into())).is_err()); + let second = service.start_call(request()).unwrap(); + release(&mut host).await; + let Interaction::Input { answers, reply, .. } = next(&mut host).await.interaction else { + panic!("expected fresh input") + }; + assert!(answers.is_empty()); + reply.send(Ok(json!(false).into())).unwrap(); + let Interaction::Record { reply, .. } = next(&mut host).await.interaction else { + panic!("expected recording") + }; + reply.send(Ok(())).unwrap(); + assert_eq!( + second.finish().await.unwrap(), + ToolResult::text(r#"{"arguments":{"path":"original"},"answer":false}"#) + ); + assert_eq!(count.load(Ordering::SeqCst), 3); +} + +#[tokio::test] +async fn identical_calls_have_independent_answers_and_out_of_order_delivery() { + let (service, mut host, count) = fixture("ask", "unattended"); + let first = service.start_call(request()).unwrap(); + release(&mut host).await; + let first_input = next(&mut host).await; + assert_eq!(first_input.call.id, first.id()); + let Interaction::Input { + reply: first_answer, + .. + } = first_input.interaction + else { + panic!("expected first input") + }; + let second = service.start_call(request()).unwrap(); + assert_ne!(first.id(), second.id()); + release(&mut host).await; + let second_input = next(&mut host).await; + assert_eq!(second_input.call.id, second.id()); + let Interaction::Input { + reply: second_answer, + answers, + .. + } = second_input.interaction + else { + panic!("expected second input") + }; + assert!(answers.is_empty()); + second_answer.send(Ok(json!(false).into())).unwrap(); + let record = next(&mut host).await; + assert_eq!(record.call.id, second.id()); + let Interaction::Record { reply, .. } = record.interaction else { + panic!("expected second recording") + }; + reply.send(Ok(())).unwrap(); + assert_eq!( + second.finish().await.unwrap(), + ToolResult::text(r#"{"arguments":{"path":"original"},"answer":false}"#) + ); + assert!(!first.is_finished()); + first_answer.send(Ok(json!(true).into())).unwrap(); + let record = next(&mut host).await; + assert_eq!(record.call.id, first.id()); + let Interaction::Record { reply, .. } = record.interaction else { + panic!("expected first recording") + }; + reply.send(Ok(())).unwrap(); + assert_eq!( + first.finish().await.unwrap(), + ToolResult::text(r#"{"arguments":{"path":"original"},"answer":true}"#) + ); + assert_eq!(count.load(Ordering::SeqCst), 4); +} + +#[tokio::test] +async fn configured_skip_never_requests_execution_release() { + let (service, mut host, count) = fixture("skip", "unattended"); + let call = service.start_call(request()).unwrap(); + let Interaction::Record { recording, reply } = next(&mut host).await.interaction else { + panic!("skip must go directly to recording") + }; + assert_eq!(recording.raw_result, None); + reply.send(Ok(())).unwrap(); + assert_eq!( + call.finish().await.unwrap(), + ToolResult::text("Tool execution skipped by configuration.") + ); + assert_eq!(count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn skipped_delivery_records_original_without_delivering_it() { + let (service, mut host, count) = fixture("ask", "skip"); + let call = service.start_call(request()).unwrap(); + release(&mut host).await; + let Interaction::Input { reply, .. } = next(&mut host).await.interaction else { + panic!("expected input") + }; + reply.send(Ok(json!(true).into())).unwrap(); + let Interaction::Record { recording, reply } = next(&mut host).await.interaction else { + panic!("expected recording, no review") + }; + // The tool's own output is recorded even though the user never sees it. + assert_eq!( + recording.raw_result, + Some(ToolResult::text( + r#"{"arguments":{"path":"original"},"answer":true}"# + )) + ); + assert_eq!( + recording.result, + ToolResult::text("Result delivery skipped by configuration.") + ); + reply.send(Ok(())).unwrap(); + assert_eq!( + call.finish().await.unwrap(), + ToolResult::text("Result delivery skipped by configuration.") + ); + assert_eq!(count.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +#[cfg(unix)] +async fn local_inquiry_exits_and_runs_a_new_process_with_the_answer() { + let root = tempdir().unwrap(); + let partial: PartialToolConfig = serde_json::from_value(json!({ + "source":"local", "run":"ask", + "command": {"program":"sh", "args":["-c", "printf 'run\\n' >> attempts; if [ \"$1\" = null ]; then printf '%s' '{\"type\":\"needs_input\",\"question\":{\"id\":\"confirm\",\"text\":\"Proceed?\",\"answer_type\":{\"type\":\"boolean\"},\"pre_amble\":null,\"default\":null}}'; else printf '%s' \"$1\"; fi", "probe", "{{tool.answers.confirm | default('null')}}"], "shell":false} + })).unwrap(); + let mut cfg = AppConfig::new_test(); + cfg.conversation.tools.insert( + "local".into(), + ToolConfig::from_partial(partial, vec![]).unwrap(), + ); + let tool = ConfiguredTool { + definition: ToolDefinition { + name: "local".into(), + docs: ToolDocs::default(), + parameters: json!({"type":"object","properties":{}}), + }, + config: cfg.conversation.tools.get("local").unwrap(), + access: Ok(None), + metadata: Map::new(), + }; + let (service, mut host) = Service::new( + vec![tool], + Client::default(), + BuiltinExecutors::new(), + root.path().to_owned(), + InvocationContext::default(), + ) + .unwrap(); + let call = service + .start_call(CallRequest { + name: "local".into(), + arguments: Map::new(), + correlation: Map::new(), + }) + .unwrap(); + release(&mut host).await; + let Interaction::Input { reply, .. } = next(&mut host).await.interaction else { + panic!("expected local input") + }; + assert_eq!( + fs::read_to_string(root.path().join("attempts")).unwrap(), + "run\n" + ); + reply.send(Ok(json!(true).into())).unwrap(); + let Interaction::Record { reply, .. } = next(&mut host).await.interaction else { + panic!("expected recording") + }; + assert_eq!( + fs::read_to_string(root.path().join("attempts")).unwrap(), + "run\nrun\n" + ); + reply.send(Ok(())).unwrap(); + assert_eq!(call.finish().await.unwrap(), ToolResult::text("true")); +} + +#[tokio::test] +async fn wrong_argument_type_fails_before_host_approval() { + let (service, _host, count) = fixture("ask", "unattended"); + let mut input = request(); + input.arguments.insert("path".into(), json!(42)); + let call = service.start_call(input).unwrap(); + let finished = timeout(Duration::from_secs(2), call.finish()) + .await + .unwrap(); + assert_matches!( + finished, + Err(ServiceError::InvalidArgument { path }) if path == "path" + ); + assert_eq!(count.load(Ordering::SeqCst), 0); +} + +struct BlockedTool { + entered: Arc, + dropped: Arc, +} +struct RunningAttempt(Arc); +impl Drop for RunningAttempt { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } +} + +#[async_trait] +impl BuiltinTool for BlockedTool { + async fn execute(&self, _: &Value, _: &IndexMap) -> Outcome { + let _attempt = RunningAttempt(self.dropped.clone()); + self.entered.notify_one(); + pending().await + } +} + +#[tokio::test] +async fn cancellation_drops_an_in_flight_builtin_attempt() { + let entered = Arc::new(Notify::new()); + let dropped = Arc::new(AtomicUsize::new(0)); + let (service, mut host) = service( + json!({"source": "builtin", "run": "ask", "result": "unattended"}), + "/tmp".into(), + BuiltinExecutors::new().register("count", BlockedTool { + entered: entered.clone(), + dropped: dropped.clone(), + }), + InvocationContext::default(), + ); + let call = service.start_call(request()).unwrap(); + release(&mut host).await; + timeout(Duration::from_secs(2), entered.notified()) + .await + .unwrap(); + assert_eq!(dropped.load(Ordering::SeqCst), 0); + call.cancel(); + assert!(matches!(call.finish().await, Err(ServiceError::Cancelled))); + assert_eq!(dropped.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn a_completed_call_delivers_the_host_result_in_place_of_its_attempt() { + let entered = Arc::new(Notify::new()); + let dropped = Arc::new(AtomicUsize::new(0)); + let (service, mut host) = service( + json!({"source": "builtin", "run": "ask", "result": "unattended"}), + "/tmp".into(), + BuiltinExecutors::new().register("count", BlockedTool { + entered: entered.clone(), + dropped: dropped.clone(), + }), + InvocationContext::default(), + ); + let call = service.start_call(request()).unwrap(); + let id = call.id(); + release(&mut host).await; + timeout(Duration::from_secs(2), entered.notified()) + .await + .unwrap(); + + assert!(service.complete_call(id, ToolResult::text("stopped by the user"))); + + let finished = timeout(Duration::from_secs(2), call.finish()) + .await + .expect("a completed call must finish"); + assert_matches!(finished, Ok(result) if result == ToolResult::text("stopped by the user")); + assert_eq!(dropped.load(Ordering::SeqCst), 1, "the attempt must stop"); + // The Host recorded the result before completing the call, so it is not + // asked to record or review it again. + assert!(matches!(host.try_recv(), Err(TryRecvError::Empty))); + service.shutdown().await; +} + +#[tokio::test] +async fn completing_a_finished_call_is_refused() { + let (service, mut host, _count) = fixture("ask", "unattended"); + let call = service.start_call(request()).unwrap(); + let id = call.id(); + let Interaction::Prepare { reply, .. } = next(&mut host).await.interaction else { + panic!("expected preparation") + }; + reply + .send(Ok(Admission::Complete { + result: ToolResult::text("denied"), + })) + .unwrap(); + let Interaction::Record { reply, .. } = next(&mut host).await.interaction else { + panic!("expected recording") + }; + reply.send(Ok(())).unwrap(); + assert_eq!(call.finish().await.unwrap(), ToolResult::text("denied")); + // The result is sent before the call leaves the active set. + timeout(Duration::from_secs(2), async { + while service.call_cancellation(id).is_some() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + + assert!(!service.complete_call(id, ToolResult::text("too late"))); + service.shutdown().await; +} + +#[tokio::test] +async fn dropping_result_receiver_does_not_cancel_or_reexecute() { + let (service, mut host, count) = fixture("ask", "unattended"); + let call = service.start_call(request()).unwrap(); + release(&mut host).await; + let Interaction::Input { reply, .. } = next(&mut host).await.interaction else { + panic!("expected input") + }; + drop(call); + reply.send(Ok(json!(true).into())).unwrap(); + let Interaction::Record { reply, .. } = next(&mut host).await.interaction else { + panic!("expected recording") + }; + reply.send(Ok(())).unwrap(); + assert_eq!(count.load(Ordering::SeqCst), 2); + service.shutdown().await; +} + +/// A service whose `count` tool formats its arguments with a shell command. +/// +/// The formatter touches `formatter-ran` in the working root, so a test can +/// tell "the formatter did not run" from "it ran and produced nothing", and +/// echoes the action and the invocation identity the service supplied it. +#[cfg(unix)] +fn formatter_fixture(mode: &str) -> (Service, HostReceiver, Utf8TempDir) { + let root = tempdir().unwrap(); + let (service, host) = service( + json!({ + "source": "builtin", + "run": "ask", + "format": mode, + "style": {"parameters": { + "program": "sh", + "args": [ + "-c", + "printf 'formatted' > formatter-ran; printf '%s' \ + '{{context.action}}:{{tool.arguments.path}}:{{context.workspace_id}}/{{context.conversation_id}}'", + ], + "shell": false, + }}, + }), + root.path(), + BuiltinExecutors::new().register("count", CountingTool(Arc::new(AtomicUsize::new(0)))), + InvocationContext { + workspace_id: "ws-abc".into(), + conversation_id: "conv-xyz".into(), + }, + ); + (service, host, root) +} + +#[tokio::test] +#[cfg(unix)] +async fn formatter_asks_for_visibility_and_waits_for_approval() { + let (service, mut host, root) = formatter_fixture("ask"); + let call = service.start_call(request()).unwrap(); + let Interaction::RenderArguments { reply } = next(&mut host).await.interaction else { + panic!("expected visibility request") + }; + reply.send(Ok(true)).unwrap(); + let Interaction::Prepare { + reply, + formatted_arguments, + .. + } = next(&mut host).await.interaction + else { + panic!("expected approval") + }; + assert!(formatted_arguments.is_none()); + assert!(!root.path().join("formatter-ran").exists()); + reply + .send(Ok(Admission::Run { + arguments: request().arguments, + })) + .unwrap(); + let Interaction::Release { + reply, + formatted_arguments, + .. + } = next(&mut host).await.interaction + else { + panic!("expected release") + }; + // The formatter runs under the action, arguments, and invocation identity + // the service supplies, not values a caller could set. + assert_eq!( + formatted_arguments.map(|result| result.map_err(|error| error.to_string())), + Some(Ok("format_arguments:original:ws-abc/conv-xyz".into())) + ); + assert_eq!( + fs::read_to_string(root.path().join("formatter-ran")).unwrap(), + "formatted" + ); + call.cancel(); + assert!(matches!(call.finish().await, Err(ServiceError::Cancelled))); + assert!(reply.send(Ok(ReleaseDecision::Execute)).is_err()); +} + +#[tokio::test] +#[cfg(unix)] +async fn unattended_formatter_is_available_before_approval() { + let (service, mut host, root) = formatter_fixture("unattended"); + let call = service.start_call(request()).unwrap(); + let Interaction::RenderArguments { reply } = next(&mut host).await.interaction else { + panic!("expected visibility request") + }; + reply.send(Ok(true)).unwrap(); + let Interaction::Prepare { + formatted_arguments, + .. + } = next(&mut host).await.interaction + else { + panic!("expected preparation") + }; + assert_eq!( + formatted_arguments.map(|result| result.map_err(|error| error.to_string())), + Some(Ok("format_arguments:original:ws-abc/conv-xyz".into())) + ); + assert!(root.path().join("formatter-ran").exists()); + call.cancel(); + assert!(matches!(call.finish().await, Err(ServiceError::Cancelled))); +} + +#[tokio::test] +#[cfg(unix)] +async fn a_formatter_is_told_the_name_the_tool_runs_under() { + // A `source` naming an implementation (`builtin.counter` under the key + // `count`) is the name the tool executes as, so the formatter is asked + // about that name rather than the key the assistant called. Handing it the + // key asks about a tool that does not exist. + let root = tempdir().unwrap(); + let (service, mut host) = service( + json!({ + "source": "builtin.counter", + "run": "ask", + "format": "unattended", + "style": {"parameters": { + "program": "sh", + "args": ["-c", "printf '%s' '{{tool.name}}'"], + "shell": false, + }}, + }), + root.path(), + BuiltinExecutors::new().register("counter", CountingTool(Arc::new(AtomicUsize::new(0)))), + InvocationContext::default(), + ); + let call = service.start_call(request()).unwrap(); + let Interaction::RenderArguments { reply } = next(&mut host).await.interaction else { + panic!("expected visibility request") + }; + reply.send(Ok(true)).unwrap(); + let Interaction::Prepare { + formatted_arguments, + .. + } = next(&mut host).await.interaction + else { + panic!("expected preparation") + }; + assert_eq!( + formatted_arguments.map(|result| result.map_err(|error| error.to_string())), + Some(Ok("counter".into())) + ); + call.cancel(); + assert!(matches!(call.finish().await, Err(ServiceError::Cancelled))); +} + +#[tokio::test] +#[cfg(unix)] +async fn hidden_presentation_never_executes_formatter() { + let (service, mut host, root) = formatter_fixture("unattended"); + let call = service.start_call(request()).unwrap(); + let Interaction::RenderArguments { reply } = next(&mut host).await.interaction else { + panic!("expected visibility request") + }; + reply.send(Ok(false)).unwrap(); + let Interaction::Prepare { + formatted_arguments, + reply, + .. + } = next(&mut host).await.interaction + else { + panic!("expected preparation") + }; + assert!(formatted_arguments.is_none()); + reply + .send(Ok(Admission::Skip { + reason: "denied".into(), + })) + .unwrap(); + let Interaction::Record { reply, .. } = next(&mut host).await.interaction else { + panic!("expected recording") + }; + reply.send(Ok(())).unwrap(); + assert_eq!(call.finish().await.unwrap(), ToolResult::text("denied")); + assert!(!root.path().join("formatter-ran").exists()); +} diff --git a/crates/jp_mcp/src/server/upstream.rs b/crates/jp_mcp/src/server/upstream.rs new file mode 100644 index 000000000..82ceeec47 --- /dev/null +++ b/crates/jp_mcp/src/server/upstream.rs @@ -0,0 +1,73 @@ +//! JP-aware result decoding for upstream MCP tools. + +use jp_tool::Outcome; +use tracing::warn; + +use crate::{CallToolResult, RawContent}; + +pub(super) enum UpstreamResult { + Outcome { + outcome: Outcome, + response: CallToolResult, + }, + Native(CallToolResult), +} + +/// Recognize one complete legacy envelope without flattening native content. +/// +/// Only a result that is exactly one text block is a candidate, and only if +/// that text parses whole. +/// Anything else is a native MCP result and is returned untouched, mixed +/// content included. +pub(super) fn decode_result(result: CallToolResult) -> Result { + let [content] = result.content.as_slice() else { + return Ok(UpstreamResult::Native(result)); + }; + let RawContent::Text(text) = &content.raw else { + return Ok(UpstreamResult::Native(result)); + }; + + match serde_json::from_str::(&text.text) { + // The server said the call failed and its payload says it succeeded. + // Per RFD 108 the flag wins, so the envelope is left unrecognized and + // the failure carries through as the native result it already is. + Ok(Outcome::Success { .. }) if result.is_error == Some(true) => { + warn!("MCP error flag conflicts with an Outcome::Success envelope"); + Ok(UpstreamResult::Native(result)) + } + Ok(outcome) => Ok(UpstreamResult::Outcome { + outcome, + response: result, + }), + // A payload shaped like an inquiry that will not parse is a protocol + // mismatch, not prose: handing the raw JSON to the model would hide it. + Err(error) if Outcome::claims_needs_input(&text.text) => Err(error), + Err(_) => Ok(UpstreamResult::Native(result)), + } +} + +/// Replace a recognized envelope's text while retaining its native metadata. +/// +/// `response` is the result [`decode_result`] recognized, so its single text +/// block is the envelope being unwrapped. +pub(super) fn replace_envelope( + mut response: CallToolResult, + text: &str, + is_error: bool, +) -> CallToolResult { + debug_assert!( + matches!(response.content.as_slice(), [content] if matches!(content.raw, RawContent::Text(_))), + "only a recognized single-text envelope can be replaced" + ); + if let Some(content) = response.content.first_mut() + && let RawContent::Text(content) = &mut content.raw + { + content.text = text.into(); + } + response.is_error = Some(is_error); + response +} + +#[cfg(test)] +#[path = "upstream_tests.rs"] +mod tests; diff --git a/crates/jp_mcp/src/server/upstream_tests.rs b/crates/jp_mcp/src/server/upstream_tests.rs new file mode 100644 index 000000000..21bd4c806 --- /dev/null +++ b/crates/jp_mcp/src/server/upstream_tests.rs @@ -0,0 +1,100 @@ +use jp_tool::Outcome; +use serde_json::json; + +use super::*; +use crate::Content; + +#[test] +fn single_text_outcome_is_unwrapped_once() { + let text = r#"{"type":"success","content":"{\"type\":\"success\",\"content\":\"nested\"}"}"#; + let output = decode_result(CallToolResult::success(vec![Content::text(text)])).unwrap(); + let UpstreamResult::Outcome { + outcome: Outcome::Success { content }, + .. + } = output + else { + panic!("expected Outcome") + }; + assert_eq!(content, r#"{"type":"success","content":"nested"}"#); +} + +#[test] +fn mixed_content_and_metadata_are_preserved() { + let input: CallToolResult = serde_json::from_value(json!({ + "content":[{"type":"text","text":"{\"type\":\"success\",\"content\":\"plain\"}"},{"type":"image","data":"AA==","mimeType":"image/png"}], + "isError":false,"structuredContent":{"answer":42},"_meta":{"custom":"retained"} + })).unwrap(); + let expected = serde_json::to_value(&input).unwrap(); + let UpstreamResult::Native(result) = decode_result(input).unwrap() else { + panic!("expected native result") + }; + assert_eq!(serde_json::to_value(result).unwrap(), expected); +} + +#[test] +fn mcp_error_flag_wins_over_success_envelope() { + let input = CallToolResult::error(vec![Content::text( + r#"{"type":"success","content":"done"}"#, + )]); + let UpstreamResult::Native(result) = decode_result(input).unwrap() else { + panic!("expected native error") + }; + assert_eq!(result.is_error, Some(true)); + assert_eq!(result.content, vec![Content::text( + r#"{"type":"success","content":"done"}"# + )]); +} + +#[test] +fn malformed_recognized_inquiry_is_not_plain_output() { + assert!( + decode_result(CallToolResult::success(vec![Content::text( + r#"{"type":"needs_input","question":{"id":"bad.id"}}"# + )])) + .is_err() + ); +} + +#[test] +fn ordinary_text_is_native() { + let UpstreamResult::Native(result) = + decode_result(CallToolResult::success(vec![Content::text("hello")])).unwrap() + else { + panic!("expected text") + }; + assert_eq!(result.content, vec![Content::text("hello")]); +} + +#[test] +fn unwrapped_envelope_preserves_annotations_and_result_metadata() { + let input: CallToolResult = serde_json::from_value(json!({ + "content":[{"type":"text","text":"{\"type\":\"success\",\"content\":\"done\"}","annotations":{"audience":["assistant"]}}], + "structuredContent":{"answer":42},"_meta":{"custom":"retained"} + })).unwrap(); + let UpstreamResult::Outcome { + outcome: Outcome::Success { content }, + response, + } = decode_result(input).unwrap() + else { + panic!("expected envelope") + }; + let result = replace_envelope(response, &content, false); + assert_eq!( + serde_json::to_value(result).unwrap(), + json!({ + "content":[{"type":"text","text":"done","annotations":{"audience":["assistant"]}}], + "isError":false,"structuredContent":{"answer":42},"_meta":{"custom":"retained"} + }) + ); +} + +#[test] +fn unrelated_error_json_is_not_a_malformed_outcome() { + let result = CallToolResult::success(vec![Content::text(r#"{"type":"error","code":42}"#)]); + let UpstreamResult::Native(result) = decode_result(result).unwrap() else { + panic!("expected native data") + }; + assert_eq!(result.content, vec![Content::text( + r#"{"type":"error","code":42}"# + )]); +} diff --git a/crates/jp_mcp/src/server_tests.rs b/crates/jp_mcp/src/server_tests.rs new file mode 100644 index 000000000..db47602c3 --- /dev/null +++ b/crates/jp_mcp/src/server_tests.rs @@ -0,0 +1,704 @@ +use async_trait::async_trait; +use camino::Utf8PathBuf; +use jp_config::{ + AppConfig, Config as _, + conversation::tool::{PartialToolConfig, ToolConfig, ToolConfigWithDefaults}, +}; +use jp_tool::{Outcome, ToolDefinition, ToolDocs}; +use serde_json::Map; + +use super::*; +use crate::Client; + +struct EchoArguments; + +#[async_trait] +impl BuiltinTool for EchoArguments { + async fn execute(&self, arguments: &Value, _answers: &IndexMap) -> Outcome { + Outcome::Success { + content: arguments.to_string(), + } + } +} + +/// The pieces an [`Execution`] borrows, owned so a test can keep them alive +/// while it builds one. +struct Fixture { + definition: ToolDefinition, + config: ToolConfigWithDefaults, + builtins: builtin::BuiltinExecutors, + upstream: Client, + root: Utf8PathBuf, + invocation: InvocationContext, +} + +impl Fixture { + /// A tool configured from `partial`, with the given name and parameters. + fn new(name: &str, partial: Value, parameters: Value) -> Self { + let partial: PartialToolConfig = serde_json::from_value(partial).unwrap(); + let mut app = AppConfig::new_test(); + app.conversation.tools.insert( + name.to_owned(), + ToolConfig::from_partial(partial, vec![]).unwrap(), + ); + Self { + definition: ToolDefinition { + name: name.to_owned(), + docs: ToolDocs::default(), + parameters, + }, + config: app.conversation.tools.get(name).unwrap(), + builtins: builtin::BuiltinExecutors::new(), + upstream: Client::new(IndexMap::new()), + root: "/tmp".into(), + invocation: InvocationContext::default(), + } + } + + fn with_builtin(mut self, name: &str, tool: impl BuiltinTool + 'static) -> Self { + self.builtins = self.builtins.register(name, tool); + self + } + + #[cfg(unix)] + fn with_invocation(mut self, invocation: InvocationContext) -> Self { + self.invocation = invocation; + self + } + + fn execution(&self, id: &str, arguments: Value) -> Execution<'_> { + Execution { + definition: &self.definition, + id: id.to_owned(), + arguments, + config: &self.config, + root: &self.root, + access: None, + invocation: &self.invocation, + builtins: &self.builtins, + upstream: &self.upstream, + cancellation: CancellationToken::new(), + stderr: None, + } + } +} + +#[test] +fn command_error_keeps_details_and_its_conversation_projection() { + let output = br#"{"type":"error","message":"busy","trace":["upstream"],"transient":true}"#; + let result = parse_command_output(output, b"", false).into_tool_result("test"); + assert_eq!( + result.status, + ToolStatus::Error(ErrorDetails { + transient: true, + trace: vec!["upstream".into()], + }) + ); + assert!(result.is_error()); + assert_eq!( + result.to_text(), + r#"{"message":"busy","trace":["upstream"]}"# + ); +} + +#[test] +fn test_execution_outcome_id() { + let completed = ExecutionOutcome::Completed { + id: "id1".to_string(), + result: ToolResult::text(""), + }; + assert_eq!(completed.id(), "id1"); + + let needs_input = ExecutionOutcome::NeedsInput { + id: "id2".to_string(), + question: Question::text("q", "?").unwrap(), + }; + assert_eq!(needs_input.id(), "id2"); + + let cancelled = ExecutionOutcome::Cancelled { + id: "id3".to_string(), + }; + assert_eq!(cancelled.id(), "id3"); +} + +#[test] +fn test_execution_outcome_helper_methods() { + let success = ExecutionOutcome::Completed { + id: "1".to_string(), + result: ToolResult::text("output"), + }; + assert!(success.is_success()); + assert!(!success.needs_input()); + assert!(!success.is_cancelled()); + + let failure = ExecutionOutcome::Completed { + id: "2".to_string(), + result: ToolResult::error("error"), + }; + assert!(!failure.is_success()); + assert!(!failure.needs_input()); + assert!(!failure.is_cancelled()); + + let needs_input = ExecutionOutcome::NeedsInput { + id: "3".to_string(), + question: Question::boolean("q", "?").unwrap(), + }; + assert!(!needs_input.is_success()); + assert!(needs_input.needs_input()); + assert!(!needs_input.is_cancelled()); + + let cancelled = ExecutionOutcome::Cancelled { + id: "4".to_string(), + }; + assert!(!cancelled.is_success()); + assert!(!cancelled.needs_input()); + assert!(cancelled.is_cancelled()); +} + +#[test] +fn parse_command_output_valid_needs_input() { + let stdout = br#"{"type":"needs_input","question":{"id":"confirm","text":"?","pre_amble":null,"answer_type":{"type":"boolean"},"default":null}}"#; + assert!(matches!( + parse_command_output(stdout, b"", true), + CommandResult::NeedsInput(_) + )); +} + +#[test] +fn parse_command_output_dotted_question_id_is_invalid_inquiry() { + let stdout = br#"{"type":"needs_input","question":{"id":"a.b","text":"?","pre_amble":null,"answer_type":{"type":"boolean"},"default":null}}"#; + let result = parse_command_output(stdout, b"", true); + assert!(matches!( + result, + CommandResult::InvalidInquiry { ref question_id } if question_id == "a.b" + )); + // Renders as a tool-level error, not raw text. + assert!(result.into_tool_result("t").is_error()); +} + +#[test] +fn parse_command_output_empty_question_id_is_invalid_inquiry() { + let stdout = br#"{"type":"needs_input","question":{"id":"","text":"?","pre_amble":null,"answer_type":{"type":"boolean"},"default":null}}"#; + let result = parse_command_output(stdout, b"", true); + assert!(matches!( + result, + CommandResult::InvalidInquiry { ref question_id } if question_id.is_empty() + )); + assert!(result.into_tool_result("t").is_error()); +} + +#[test] +fn parse_command_output_legacy_answer_type_shape_is_malformed_inquiry() { + // A stale local-tool binary emits the pre-082 externally-tagged answer + // type (`"answer_type":"Boolean"`) instead of the internally-tagged + // `{"type":"boolean"}` this build parses. The question id is valid, so + // the payload must surface as a tool-level error rather than being handed + // to the model as raw JSON. + let stdout = br#"{"type":"needs_input","question":{"id":"apply_changes","text":"Apply?","answer_type":"Boolean","default":true}}"#; + let result = parse_command_output(stdout, b"", true); + assert!( + matches!(result, CommandResult::MalformedInquiry { .. }), + "expected MalformedInquiry, got {result:?}" + ); + // Renders as a tool-level error, not raw text. + assert!(result.into_tool_result("fs_modify_file").is_error()); +} + +#[test] +fn parse_command_output_needs_input_missing_field_is_malformed_inquiry() { + // A `needs_input` missing a required question field fails to deserialize; + // with a valid id it is a malformed inquiry, not raw output. + let stdout = br#"{"type":"needs_input","question":{"id":"confirm"}}"#; + let result = parse_command_output(stdout, b"", true); + assert!( + matches!(result, CommandResult::MalformedInquiry { .. }), + "expected MalformedInquiry, got {result:?}" + ); + assert!(result.into_tool_result("t").is_error()); +} + +#[test] +fn parse_command_output_non_outcome_is_raw() { + assert!(matches!( + parse_command_output(b"plain text", b"", true), + CommandResult::RawOutput { .. } + )); +} + +#[test] +fn parse_command_output_non_needs_input_json_is_raw() { + // Valid JSON that is not an `Outcome` and not a `needs_input` payload + // stays raw output — the malformed-inquiry path must not swallow it. + let stdout = br#"{"some":"object","the_tool":"did not use the protocol"}"#; + assert!(matches!( + parse_command_output(stdout, b"", true), + CommandResult::RawOutput { .. } + )); +} + +/// Build a parameters schema from `(name, node, required)` triples. +fn schema(properties: [(&str, Value, bool); N]) -> Value { + let required = properties + .iter() + .filter(|(_, _, required)| *required) + .map(|(name, _, _)| Value::String((*name).to_owned())) + .collect::>(); + let properties = properties + .into_iter() + .map(|(name, node, _)| (name.to_owned(), node)) + .collect::>(); + + json!({ "type": "object", "properties": properties, "required": required }) +} + +/// A schema node of the given type. +fn param(kind: &str) -> Value { + json!({ "type": kind }) +} + +#[tokio::test] +async fn local_tool_rejects_scalar_enum_on_array_parameter() { + let partial: PartialToolConfig = serde_json::from_value(json!({ + "source": "local", + "parameters": { + "tags": { + "type": "array", + "enum": ["projects/jp", "task", "idea"], + "items": { "type": "string" } + } + } + })) + .unwrap(); + let tool = ToolConfig::from_partial(partial, vec![]).unwrap(); + let mut app = AppConfig::new_test(); + app.conversation + .tools + .insert("bear_note_create".to_owned(), tool); + let config = app.conversation.tools.get("bear_note_create").unwrap(); + + let error = resolve_tool("bear_note_create", &config, &Client::new(IndexMap::new())) + .await + .unwrap_err(); + + assert_eq!( + error.to_string(), + "Invalid schema at `conversation.tools.bear_note_create.parameters.tags.enum`: enum value \ + \"projects/jp\" has type string, but the schema requires array; use \ + `conversation.tools.bear_note_create.parameters.tags.items.enum` to constrain array \ + elements" + ); +} + +#[tokio::test] +async fn execute_coerces_json_strings_before_calling_tool() { + let fixture = Fixture::new( + "echo_arguments", + json!({"source": "builtin"}), + schema([("start_line", param("integer"), false)]), + ) + .with_builtin("echo_arguments", EchoArguments); + + let outcome = execute( + &fixture.execution("call_1", json!({"start_line": "1"})), + &Answers::new(), + ) + .await + .unwrap(); + + let ExecutionOutcome::Completed { id, result, .. } = outcome else { + panic!("expected completed tool call"); + }; + assert_eq!(id, "call_1"); + assert_eq!(result, ToolResult::text(r#"{"start_line":1}"#)); +} + +/// Regression: `{{tool}}` must render as valid JSON, including `null` for null +/// fields (not Jinja2's `none`). +/// Originally fixed with `AutoEscape::Json`, now handled by the custom +/// formatter which JSON-serializes composite values while leaving scalars +/// alone. +#[tokio::test] +#[cfg(unix)] +async fn test_run_tool_command_renders_null_args_as_valid_json() { + use jp_config::conversation::tool::CommandConfig; + + let ctx = json!({ + "tool": { + "name": "cargo_test", + "arguments": { + "package": "jp_workspace", + "backtrace": null, + "testname": null, + }, + "answers": {}, + "options": {}, + }, + "context": { + "action": "run", + "root": "/tmp", + }, + }); + + let command = CommandConfig { + program: "echo".to_owned(), + args: vec!["{{tool}}".to_owned()], + shell: false, + }; + + let result = run_tool_command(command, ctx, "/tmp".into(), CancellationToken::new(), None) + .await + .unwrap(); + + let stdout = match result { + CommandResult::RawOutput { stdout, .. } => stdout, + other => panic!("Expected RawOutput, got: {other:?}"), + }; + + // The rendered output must be valid JSON with proper `null` values. + let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!("run_tool_command produced invalid JSON: {e}\n\nOutput: {stdout}") + }); + + assert_eq!(parsed["arguments"]["package"], "jp_workspace"); + assert_eq!(parsed["arguments"]["backtrace"], Value::Null); + assert_eq!(parsed["name"], "cargo_test"); +} + +/// Regression: scalar string interpolation must not be JSON-quoted. +/// A prior fix for the null-rendering bug set `AutoEscape::Json` globally, +/// which wrapped every string value in literal `"..."`, breaking templates like +/// `just rfd-draft {{tool.arguments.title}}` where tool authors expect the bare +/// value. +#[tokio::test] +#[cfg(unix)] +async fn test_run_tool_command_renders_scalar_strings_raw() { + use jp_config::conversation::tool::CommandConfig; + + let ctx = json!({ + "tool": { + "arguments": { "title": "Hello World" }, + }, + }); + + let command = CommandConfig { + program: "echo".to_owned(), + args: vec!["{{tool.arguments.title}}".to_owned()], + shell: false, + }; + + let result = run_tool_command(command, ctx, "/tmp".into(), CancellationToken::new(), None) + .await + .unwrap(); + + let stdout = match result { + CommandResult::RawOutput { stdout, .. } => stdout, + other => panic!("Expected RawOutput, got: {other:?}"), + }; + + assert_eq!(stdout.trim_end(), "Hello World"); +} + +/// Null scalars render as literal `null` (not Jinja2's `none`, and not an empty +/// string). +/// This keeps the behavior consistent with how null appears inside +/// JSON-serialized composites. +#[tokio::test] +#[cfg(unix)] +async fn test_run_tool_command_renders_null_scalar_as_literal_null() { + use jp_config::conversation::tool::CommandConfig; + + let ctx = json!({ + "tool": { "arguments": { "maybe": null } }, + }); + + let command = CommandConfig { + program: "echo".to_owned(), + args: vec!["{{tool.arguments.maybe}}".to_owned()], + shell: false, + }; + + let result = run_tool_command(command, ctx, "/tmp".into(), CancellationToken::new(), None) + .await + .unwrap(); + + let stdout = match result { + CommandResult::RawOutput { stdout, .. } => stdout, + other => panic!("Expected RawOutput, got: {other:?}"), + }; + + assert_eq!(stdout.trim_end(), "null"); +} + +/// End-to-end sanity check for the rfd-draft regression: with the old +/// `AutoEscape::Json` behavior, `{{tool.arguments.title}}` rendered as +/// `"Assistant-Initiated ..."` (literal quotes), which then broke the +/// downstream `sed` command inside the just recipe. +/// Verify the title now reaches the subprocess as a clean argument. +#[tokio::test] +#[cfg(unix)] +async fn test_run_tool_command_rfd_draft_title_rendering() { + use jp_config::conversation::tool::CommandConfig; + + let ctx = json!({ + "tool": { + "arguments": { + "variant": "design", + "title": "Assistant-Initiated User Inquiries via an ask_user Builtin", + }, + }, + }); + + // Mimic the real `just rfd-draft {{variant}} {{title}}` template. + let command = CommandConfig { + program: "printf".to_owned(), + args: vec![ + "%s|%s".to_owned(), + "{{tool.arguments.variant}}".to_owned(), + "{{tool.arguments.title}}".to_owned(), + ], + shell: false, + }; + + let result = run_tool_command(command, ctx, "/tmp".into(), CancellationToken::new(), None) + .await + .unwrap(); + + let stdout = match result { + CommandResult::RawOutput { stdout, .. } => stdout, + other => panic!("Expected RawOutput, got: {other:?}"), + }; + + assert_eq!( + stdout, + "design|Assistant-Initiated User Inquiries via an ask_user Builtin" + ); +} + +/// The `tojson` filter still works for tool authors who want explicit +/// JSON-quoted strings (e.g. when hand-crafting a JSON literal). +/// Safe strings produced by `tojson` must pass through the custom formatter +/// unchanged — no double-encoding. +#[tokio::test] +#[cfg(unix)] +async fn test_run_tool_command_tojson_filter_on_scalar_still_works() { + use jp_config::conversation::tool::CommandConfig; + + let ctx = json!({ + "tool": { "arguments": { "title": "Hello" } }, + }); + + let command = CommandConfig { + program: "echo".to_owned(), + args: vec!["{{tool.arguments.title | tojson}}".to_owned()], + shell: false, + }; + + let result = run_tool_command(command, ctx, "/tmp".into(), CancellationToken::new(), None) + .await + .unwrap(); + + let stdout = match result { + CommandResult::RawOutput { stdout, .. } => stdout, + other => panic!("Expected RawOutput, got: {other:?}"), + }; + + assert_eq!(stdout.trim_end(), "\"Hello\""); +} + +/// Regression: the `run` path must surface the invocation's workspace and +/// conversation IDs to the tool command via `context.workspace_id` and +/// `context.conversation_id`. +/// A non-empty `InvocationContext` pins the wiring so the fields can't be +/// silently dropped or emptied. +#[tokio::test] +#[cfg(unix)] +async fn test_execute_local_exposes_invocation_ids_in_context() { + let fixture = Fixture::new( + "echo_ids", + json!({ + "source": "local", + "command": "echo {{context.workspace_id}}-{{context.conversation_id}}", + }), + schema([]), + ) + .with_invocation(InvocationContext { + workspace_id: "ws-abc".to_owned(), + conversation_id: "conv-xyz".to_owned(), + }); + + let outcome = execute(&fixture.execution("call-1", json!({})), &Answers::new()) + .await + .expect("execution succeeds"); + + match outcome { + ExecutionOutcome::Completed { result, .. } => { + assert_eq!(result, ToolResult::text("ws-abc-conv-xyz\n")); + } + other => panic!("expected completed success, got: {other:?}"), + } +} + +/// A built-in that reports it ran, so dispatch can be observed. +struct ReachedBuiltin; + +#[async_trait::async_trait] +impl builtin::BuiltinTool for ReachedBuiltin { + async fn execute(&self, _: &Value, _: &IndexMap) -> jp_tool::Outcome { + "reached".into() + } +} + +/// A built-in tool may be keyed differently from the implementation it names: +/// `source = "builtin.describe_tools"` under a `docs` key. +/// Dispatch keys on the source's tool name, matching how the local and MCP +/// paths treat theirs. +#[tokio::test] +async fn test_execute_builtin_dispatches_on_source_name() { + let fixture = Fixture::new( + "docs", + json!({"source": "builtin.describe_tools"}), + schema([]), + ) + .with_builtin("describe_tools", ReachedBuiltin); + + let outcome = execute(&fixture.execution("call-1", json!({})), &Answers::new()) + .await + .expect("execution succeeds"); + + match outcome { + ExecutionOutcome::Completed { result, .. } => { + assert_eq!(result, ToolResult::text("reached")); + } + other => panic!("expected completed success, got: {other:?}"), + } +} + +/// Regression for RFD 081: `tool_definitions` keeps a *forced* tool that is +/// merely disabled (`OFF`), but always drops a locked-off tool (`state = +/// false`, `allow_toggle = never`) even when it is forced. +#[tokio::test] +async fn test_tool_definitions_forced_tool_drops_locked_off() { + use jp_config::{ + AppConfig, Config, + conversation::tool::{PartialToolConfig, ToolConfig}, + }; + + let off: PartialToolConfig = serde_json::from_value(json!({ + "source": "local", + "command": "echo off", + "enable": false, + })) + .expect("valid partial tool config"); + let locked_off: PartialToolConfig = serde_json::from_value(json!({ + "source": "local", + "command": "echo locked", + "enable": { "state": false, "allow_toggle": "never" }, + })) + .expect("valid partial tool config"); + + let mut cfg = AppConfig::new_test(); + cfg.conversation.tools.insert( + "off_tool".to_owned(), + ToolConfig::from_partial(off, vec![]).expect("resolved tool config"), + ); + cfg.conversation.tools.insert( + "locked_off_tool".to_owned(), + ToolConfig::from_partial(locked_off, vec![]).expect("resolved tool config"), + ); + + let mcp_client = Client::new(IndexMap::new()); + + // Forcing the toggleable OFF tool keeps it in the definitions. + let defs = tool_definitions(cfg.conversation.tools.iter(), &mcp_client, Some("off_tool")) + .await + .expect("tool definitions resolve"); + assert!( + defs.iter().any(|d| d.name == "off_tool"), + "a forced toggleable OFF tool must be kept" + ); + + // Forcing the locked-off tool still drops it. + let defs = tool_definitions( + cfg.conversation.tools.iter(), + &mcp_client, + Some("locked_off_tool"), + ) + .await + .expect("tool definitions resolve"); + assert!( + !defs.iter().any(|d| d.name == "locked_off_tool"), + "a locked-off tool must be dropped even when forced" + ); +} + +/// A tool whose schema cannot be resolved is dropped from the request rather +/// than failing the whole query, mirroring how an unavailable MCP server is +/// handled. +#[tokio::test] +async fn tool_with_an_unresolvable_schema_is_skipped() { + let broken: PartialToolConfig = serde_json::from_value(json!({ + "source": "local", + "command": "echo broken", + "parameters": { "tags": { "type": "array" } }, + })) + .expect("valid partial tool config"); + let healthy: PartialToolConfig = serde_json::from_value(json!({ + "source": "local", + "command": "echo fine", + "parameters": { "path": { "type": "string" } }, + })) + .expect("valid partial tool config"); + + let mut cfg = AppConfig::new_test(); + cfg.conversation.tools.insert( + "broken_tool".to_owned(), + ToolConfig::from_partial(broken, vec![]).expect("resolved tool config"), + ); + cfg.conversation.tools.insert( + "healthy_tool".to_owned(), + ToolConfig::from_partial(healthy, vec![]).expect("resolved tool config"), + ); + + let defs = tool_definitions( + cfg.conversation.tools.iter(), + &Client::new(IndexMap::new()), + None, + ) + .await + .expect("a broken tool must not fail the query"); + + let names = defs.iter().map(|d| d.name.as_str()).collect::>(); + assert_eq!(names, vec!["healthy_tool"]); +} + +/// Naming a tool with `--tool` is an explicit request for it, so its schema +/// error surfaces instead of the tool silently disappearing. +#[tokio::test] +async fn forced_tool_with_an_unresolvable_schema_still_errors() { + let broken: PartialToolConfig = serde_json::from_value(json!({ + "source": "local", + "command": "echo broken", + "parameters": { "tags": { "type": "array" } }, + })) + .expect("valid partial tool config"); + + let mut cfg = AppConfig::new_test(); + cfg.conversation.tools.insert( + "broken_tool".to_owned(), + ToolConfig::from_partial(broken, vec![]).expect("resolved tool config"), + ); + + let error = tool_definitions( + cfg.conversation.tools.iter(), + &Client::new(IndexMap::new()), + Some("broken_tool"), + ) + .await + .unwrap_err(); + + assert_eq!( + error.to_string(), + "Invalid schema at `conversation.tools.broken_tool.parameters.tags.items`: array schemas \ + must declare an item schema" + ); +} diff --git a/crates/jp_tool/Cargo.toml b/crates/jp_tool/Cargo.toml index 972ef5a3b..b72f2c8ef 100644 --- a/crates/jp_tool/Cargo.toml +++ b/crates/jp_tool/Cargo.toml @@ -14,6 +14,7 @@ version.workspace = true [dependencies] camino = { workspace = true, features = ["serde1"] } +indexmap = { workspace = true } serde = { workspace = true, features = ["std", "derive"] } serde_json = { workspace = true, features = ["std", "preserve_order"] } thiserror = { workspace = true } diff --git a/crates/jp_tool/src/content.rs b/crates/jp_tool/src/content.rs new file mode 100644 index 000000000..78e40afba --- /dev/null +++ b/crates/jp_tool/src/content.rs @@ -0,0 +1,510 @@ +//! What a tool execution attempt produced, and what it is asking for. +//! +//! [`ToolResult`] is the ordered content one attempt returned, plus whether it +//! failed. +//! A [`ContentBlock`] is one piece of that content: text, a resource, or a +//! request for input. +//! +//! The shape follows MCP's, so a result that arrives from an MCP server carries +//! across without being flattened on the way in, and one JP assembles itself +//! can be handed back out. +//! Fields MCP defines and JP does not act on are carried as data rather than +//! dropped. +//! +//! Tools speaking the [`Outcome`] protocol are converted at the boundary; see +//! the `From` implementations below. +//! +//! Which of these shapes a tool can actually produce depends on where it runs. +//! An upstream MCP server returns a native MCP result, so every variant is +//! reachable. +//! A local command and a built-in both speak [`Outcome`], which carries text, +//! an error, or a question and nothing else, so a result from either is always +//! text. +//! Closing that gap is RFD 058's work. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use crate::{AnswerType, Outcome, Question, QuestionId}; + +/// What one tool execution attempt produced. +/// +/// An attempt that ends by asking for input is not a failure: its content +/// carries a [`ContentBlock::Question`], and the caller runs the tool again +/// once it has the answer. +#[derive(Debug, Clone, PartialEq)] +pub struct ToolResult { + /// The blocks the tool produced, in the order it produced them. + pub content: Vec, + + /// Whether the tool reported success or failure. + pub status: ToolStatus, + + /// Structured data supplied alongside the ordered content. + pub structured_content: Option, + + /// Opaque protocol metadata, preserved across forwarding. + pub metadata: Option>, +} + +impl ToolResult { + /// Whether the result reports a tool failure rather than a service failure. + #[must_use] + pub fn is_error(&self) -> bool { + matches!(self.status, ToolStatus::Error(_)) + } + + /// Failure details supplied by the tool, if any. + #[must_use] + pub fn error_details(&self) -> Option<&ErrorDetails> { + match &self.status { + ToolStatus::Error(error) => Some(error), + ToolStatus::Success | ToolStatus::Unspecified => None, + } + } + + /// A successful result carrying one text block. + #[must_use] + pub fn text(text: impl Into) -> Self { + Self { + content: vec![ContentBlock::text(text)], + status: ToolStatus::Success, + structured_content: None, + metadata: None, + } + } + + /// A failed result carrying one text block and no further detail. + #[must_use] + pub fn error(text: impl Into) -> Self { + Self { + content: vec![ContentBlock::text(text)], + status: ToolStatus::Error(ErrorDetails::default()), + structured_content: None, + metadata: None, + } + } + + /// The first input request in the content, when the tool is asking for one. + #[must_use] + pub fn input_request(&self) -> Option<&InputRequest> { + self.content.iter().find_map(|block| match block { + ContentBlock::Question(request) => Some(request), + _ => None, + }) + } + + /// Flatten the content to the text a provider receives. + /// + /// Text blocks and the text side of resources are joined with a blank line, + /// in content order; a binary resource contributes its URI, since its bytes + /// are not text. + /// Error metadata is not appended to the tool's content. + /// + /// Callers that render blocks themselves should read [`content`] instead. + /// + /// [`content`]: Self::content + #[must_use] + pub fn to_text(&self) -> String { + self.content + .iter() + .filter_map(ContentBlock::as_text) + .collect::>() + .join("\n\n") + } +} + +/// The status reported by a tool, with failure details attached only to errors. +#[derive(Debug, Clone, PartialEq)] +pub enum ToolStatus { + /// The upstream protocol omitted its optional status field. + Unspecified, + /// The tool explicitly reported success. + Success, + /// The tool reported failure, with optional details. + Error(ErrorDetails), +} + +/// Detail a tool attached to a failure. +/// +/// Arrives as `_meta["computer.jp/error"]` on an MCP-shaped result. +/// A failure without it is non-transient with no trace. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct ErrorDetails { + /// Whether running the tool again could succeed. + pub transient: bool, + + /// The error's source chain, outermost first. + pub trace: Vec, +} + +/// One piece of a tool's output. +#[derive(Debug, Clone, PartialEq)] +pub enum ContentBlock { + /// Text for the model to read. + Text { + text: String, + + /// The format of the text, when the tool declared one. + /// + /// MCP tools never set it; `None` means plain text. + mime_type: Option, + + /// MCP annotations, carried but not acted on. + annotations: Option, + + /// Opaque protocol metadata for this text block. + metadata: Option>, + }, + + /// A resource the tool produced or read. + Resource(Resource), + + /// Base64-encoded image content. + Image(ImageContent), + + /// Base64-encoded audio content. + Audio { + /// Base64-encoded audio bytes. + data: String, + /// Media type of the audio data. + mime_type: String, + /// Audience, priority, and modification time. + annotations: Option, + }, + + /// A resource reference without embedded content. + ResourceLink(ResourceLink), + + /// Input the tool needs before it can finish. + Question(InputRequest), +} + +impl ContentBlock { + /// A plain text block. + #[must_use] + pub fn text(text: impl Into) -> Self { + Self::Text { + text: text.into(), + mime_type: None, + annotations: None, + metadata: None, + } + } + + /// The block's text, for a caller assembling a plain-text result. + /// + /// A resource contributes its text content, or its URI when the content is + /// binary. + /// A question contributes nothing: it is answered, not read. + #[must_use] + pub fn as_text(&self) -> Option<&str> { + match self { + Self::Text { text, .. } => Some(text), + Self::Resource(resource) => match &resource.content { + ResourceContent::Text(text) => Some(text), + ResourceContent::Blob(_) | ResourceContent::EncodedBlob(_) => Some(&resource.uri), + }, + Self::Question(_) | Self::Image(_) | Self::Audio { .. } | Self::ResourceLink(_) => None, + } + } +} + +/// A resource, identified by URI and carrying its content. +/// +/// Embedded resource content and its protocol metadata remain separate from +/// optional presentation information. +#[derive(Debug, Clone, PartialEq)] +pub struct Resource { + /// The URI identifying this resource. + pub uri: String, + + /// The resource's content. + pub content: ResourceContent, + + /// The content's media type, such as `text/rust` or `image/png`. + pub mime_type: Option, + + /// MCP annotations, carried but not acted on. + pub annotations: Option, + + /// A short name for the resource. + pub name: Option, + + /// A human-readable title, falling back to `name` and then `uri`. + pub title: Option, + + /// What the resource is. + pub description: Option, + + /// Content already formatted for the model. + /// + /// Available to rendering consumers; raw-content projections leave it out. + pub formatted: Option, + + /// Opaque metadata on the enclosing content block. + pub metadata: Option>, + + /// Opaque metadata on the embedded resource itself. + pub content_metadata: Option>, +} + +impl Resource { + /// A text resource with no metadata beyond its URI. + #[must_use] + pub fn text(uri: impl Into, text: impl Into) -> Self { + Self { + uri: uri.into(), + content: ResourceContent::Text(text.into()), + mime_type: None, + annotations: None, + name: None, + title: None, + description: None, + formatted: None, + metadata: None, + content_metadata: None, + } + } +} + +/// A resource's content, matching MCP's text-or-blob model. +#[derive(Debug, Clone, PartialEq)] +pub enum ResourceContent { + /// UTF-8 text. + Text(String), + + /// Bytes, such as an image or a PDF. + Blob(Vec), + + /// Base64 data received from MCP, retained without rewriting its encoding. + EncodedBlob(String), +} + +/// An image block, retaining its encoded bytes, media type, and metadata. +#[derive(Debug, Clone, PartialEq)] +pub struct ImageContent { + /// Base64-encoded data as supplied by the tool. + pub data: String, + /// Media type of the encoded data. + pub mime_type: String, + /// Audience, priority, and modification time. + pub annotations: Option, + /// Opaque protocol metadata. + pub metadata: Option>, +} + +/// An MCP resource reference without embedded content. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResourceLink { + /// URI identifying the resource. + pub uri: String, + /// Machine-readable resource name. + pub name: String, + /// Display title. + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + /// Human-readable description. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Resource media type. + #[serde(skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + /// Declared resource size in bytes. + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, + /// Resource icons and their presentation hints. + #[serde(skip_serializing_if = "Option::is_none")] + pub icons: Option>, + /// Audience, priority, and modification time. + #[serde(skip_serializing_if = "Option::is_none")] + pub annotations: Option, + /// Opaque protocol metadata. + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub metadata: Option>, +} + +/// An icon supplied with a resource reference. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResourceIcon { + /// URI of the icon, including data URIs. + pub src: String, + /// Icon media type. + #[serde(skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + /// Declared image dimensions, using MCP's size notation. + #[serde(skip_serializing_if = "Option::is_none")] + pub sizes: Option>, + /// Background theme for which the icon is intended. + #[serde(skip_serializing_if = "Option::is_none")] + pub theme: Option, +} + +/// Background theme of a resource icon. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum IconTheme { + Light, + Dark, +} + +/// MCP annotations on a block or resource. +/// +/// Carried so a result that arrives with them can be handed back out intact. +/// Nothing in JP reads them. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Annotations { + /// Who the content is meant for. + #[serde(skip_serializing_if = "Option::is_none")] + pub audience: Option>, + + /// How important the content is, from `0.0` to `1.0`. + #[serde(skip_serializing_if = "Option::is_none")] + pub priority: Option, + + /// When the content last changed, as an ISO 8601 timestamp. + #[serde(skip_serializing_if = "Option::is_none")] + pub last_modified: Option, +} + +/// A party in the conversation, as MCP names them. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Role { + User, + Assistant, +} + +/// Input a tool needs before it can finish. +/// +/// Who answers is the host's decision, not the tool's: the same request can be +/// put to the user, answered from configuration, or sent to an assistant. +#[derive(Debug, Clone, PartialEq)] +pub struct InputRequest { + /// Identifies the request, and keys the answer on re-execution. + pub id: QuestionId, + + /// A one-line prompt shown beside the input. + /// + /// Supporting material belongs in the content blocks preceding this one. + pub label: String, + + /// What kind of answer the tool expects. + /// + /// Both the [`schema`] an answer is validated against and the widget a host + /// prompts with come from this, so a request that crosses a service + /// boundary and comes back describes the same input it started as. + /// + /// [`schema`]: Self::schema + pub answer_type: AnswerType, + + /// The answer used when none is given. + pub default: Option, +} + +impl InputRequest { + /// The JSON Schema an answer must satisfy. + #[must_use] + pub fn schema(&self) -> Map { + self.answer_type.to_schema() + } + + /// Whether the answer must not be written to disk. + /// + /// A secret answer is not echoed while it is typed, and the recorded + /// inquiry response holds a redaction marker rather than the answer. + #[must_use] + pub fn is_secret(&self) -> bool { + matches!(self.answer_type, AnswerType::Secret) + } +} + +impl From for InputRequest { + fn from(question: Question) -> Self { + let Question { + id, + text, + pre_amble: _, + answer_type, + default, + } = question; + + Self { + id, + label: text, + answer_type, + default, + } + } +} + +impl AnswerType { + /// The JSON Schema an answer of this type must satisfy. + /// + /// A secret answer is a string like any other: that it must not be + /// persisted is a property of the answer type, not of the schema, so the + /// rule cannot be lost by rewriting the schema. + #[must_use] + pub fn to_schema(&self) -> Map { + let schema = match self { + Self::Boolean => json!({ "type": "boolean" }), + Self::Select { options } => json!({ "type": "string", "enum": options }), + Self::Text | Self::Secret => json!({ "type": "string" }), + }; + + schema.as_object().cloned().unwrap_or_default() + } +} + +impl From> for ToolResult { + fn from(result: Result) -> Self { + match result { + Ok(text) => Self::text(text), + Err(text) => Self::error(text), + } + } +} + +impl From for ToolResult { + fn from(outcome: Outcome) -> Self { + match outcome { + Outcome::Success { content } => Self::text(content), + Outcome::Error { + message, + trace, + transient, + } => Self { + content: vec![ContentBlock::text(if trace.is_empty() { + message + } else { + format!("{message}\n\nTrace:\n{}", trace.join("\n")) + })], + status: ToolStatus::Error(ErrorDetails { transient, trace }), + structured_content: None, + metadata: None, + }, + Outcome::NeedsInput { mut question } => { + let mut content: Vec<_> = question + .pre_amble + .take() + .into_iter() + .map(ContentBlock::text) + .collect(); + content.push(ContentBlock::Question(question.into())); + Self { + content, + status: ToolStatus::Success, + structured_content: None, + metadata: None, + } + } + } + } +} + +#[cfg(test)] +#[path = "content_tests.rs"] +mod tests; diff --git a/crates/jp_tool/src/content_tests.rs b/crates/jp_tool/src/content_tests.rs new file mode 100644 index 000000000..4add603a5 --- /dev/null +++ b/crates/jp_tool/src/content_tests.rs @@ -0,0 +1,225 @@ +use serde_json::json; + +use super::*; + +fn question(id: &str, answer_type: AnswerType) -> Question { + Question { + id: id.parse().unwrap(), + text: "Which branch?".to_owned(), + pre_amble: Some("A preamble the request does not carry.".to_owned()), + answer_type, + default: Some(json!("main")), + } +} + +#[test] +fn a_successful_outcome_becomes_one_text_block() { + let result = ToolResult::from(Outcome::Success { + content: "done".to_owned(), + }); + + assert_eq!(result, ToolResult { + content: vec![ContentBlock::text("done")], + status: ToolStatus::Success, + structured_content: None, + metadata: None, + }); +} + +#[test] +fn a_failed_outcome_keeps_its_trace_and_transience() { + let result = ToolResult::from(Outcome::Error { + message: "File not found: foo.rs".to_owned(), + trace: vec!["io error: No such file or directory".to_owned()], + transient: true, + }); + + assert_eq!(result, ToolResult { + content: vec![ContentBlock::text( + "File not found: foo.rs\n\nTrace:\nio error: No such file or directory" + )], + structured_content: None, + metadata: None, + status: ToolStatus::Error(ErrorDetails { + transient: true, + trace: vec!["io error: No such file or directory".to_owned()], + }), + }); +} + +/// A tool that stops to ask something has not failed: the caller answers and +/// runs it again. +#[test] +fn a_needs_input_outcome_is_not_an_error() { + let result = ToolResult::from(Outcome::NeedsInput { + question: question("target", AnswerType::Text), + }); + + assert!(!result.is_error()); + assert_eq!(result.error_details(), None); + assert_eq!( + result.input_request().map(|r| r.id.as_str()), + Some("target") + ); +} + +#[test] +fn outcome_conversion_preserves_question_context() { + let result = ToolResult::from(Outcome::NeedsInput { + question: question("target", AnswerType::Text), + }); + assert_eq!(result.to_text(), "A preamble the request does not carry."); + assert_eq!(result.content.len(), 2); +} + +#[test] +fn a_question_keeps_its_answer_type_and_derives_an_enum_schema() { + let answer_type = AnswerType::Select { + options: vec!["main".to_owned(), "develop".to_owned()], + }; + let request = InputRequest::from(question("branch", answer_type.clone())); + + assert_eq!(request, InputRequest { + id: "branch".parse().unwrap(), + label: "Which branch?".to_owned(), + answer_type, + default: Some(json!("main")), + }); + assert_eq!( + request.schema(), + json!({ "type": "string", "enum": ["main", "develop"] }) + .as_object() + .cloned() + .unwrap() + ); +} + +#[test] +fn a_boolean_question_derives_a_boolean_schema() { + let request = InputRequest::from(question("proceed", AnswerType::Boolean)); + + assert_eq!(request.answer_type, AnswerType::Boolean); + assert_eq!( + request.schema(), + json!({ "type": "boolean" }).as_object().cloned().unwrap() + ); +} + +/// Secrecy rides on the answer type, not on a schema keyword: a consumer that +/// rewrites the schema for a provider cannot drop the rule that the answer +/// stays off disk. +#[test] +fn a_secret_question_derives_a_plain_string_schema_and_stays_secret() { + let request = InputRequest::from(question("token", AnswerType::Secret)); + + assert!(request.is_secret()); + assert_eq!( + request.schema(), + json!({ "type": "string" }).as_object().cloned().unwrap() + ); +} + +/// A text question derives the same schema as a secret one, which is exactly +/// why the schema cannot be what tells them apart. +#[test] +fn an_ordinary_text_question_is_not_secret() { + let request = InputRequest::from(question("name", AnswerType::Text)); + + assert!(!request.is_secret()); + assert_eq!( + request.schema(), + InputRequest::from(question("token", AnswerType::Secret)).schema() + ); +} + +/// Every answer type comes back as itself after a request crosses the service +/// boundary, including the two that share a schema and the one whose options a +/// schema-only representation would have to re-read. +#[test] +fn every_answer_type_survives_the_request_round_trip() { + let types = [ + AnswerType::Text, + AnswerType::Secret, + AnswerType::Boolean, + AnswerType::Select { + options: vec!["main".to_owned(), "develop".to_owned()], + }, + ]; + + for answer_type in types { + let original = question("q", answer_type.clone()); + let request = InputRequest::from(original.clone()); + + let mut restored = Question::new(request.id, request.label, request.answer_type); + restored.default = request.default; + restored.pre_amble = original.pre_amble.clone(); + + assert_eq!(restored, original, "round trip lost {answer_type:?}"); + } +} + +#[test] +fn flattening_joins_blocks_in_order_with_a_blank_line() { + let result = ToolResult { + content: vec![ + ContentBlock::text("first"), + ContentBlock::Resource(Resource::text("file:///a.rs", "second")), + ContentBlock::text("third"), + ], + status: ToolStatus::Success, + structured_content: None, + metadata: None, + }; + + assert_eq!(result.to_text(), "first\n\nsecond\n\nthird"); +} + +/// A blob has no text to contribute, so its URI stands in for it rather than +/// its bytes reaching the model as mojibake. +#[test] +fn flattening_names_a_binary_resource_by_its_uri() { + let result = ToolResult { + content: vec![ContentBlock::Resource(Resource { + content: ResourceContent::Blob(vec![0x89, 0x50, 0x4e, 0x47]), + ..Resource::text("file:///shot.png", "") + })], + status: ToolStatus::Success, + structured_content: None, + metadata: None, + }; + + assert_eq!(result.to_text(), "file:///shot.png"); +} + +/// The question is answered, not read: flattening a result that carries one +/// must not put the prompt in front of the model as output. +#[test] +fn flattening_omits_a_question_but_keeps_its_context() { + let result = ToolResult { + content: vec![ + ContentBlock::text("Two hunks remain."), + ContentBlock::Question(InputRequest::from(question("stage", AnswerType::Boolean))), + ], + status: ToolStatus::Success, + structured_content: None, + metadata: None, + }; + + assert_eq!(result.to_text(), "Two hunks remain."); +} + +#[test] +fn outcome_error_trace_is_rendered_once() { + let result = ToolResult::from(Outcome::Error { + message: "failed".to_owned(), + trace: vec!["inner".to_owned(), "innermost".to_owned()], + transient: false, + }); + + assert_eq!(result.to_text(), "failed\n\nTrace:\ninner\ninnermost"); +} + +#[test] +fn flattening_an_error_without_a_trace_is_just_the_message() { + assert_eq!(ToolResult::error("failed").to_text(), "failed"); +} diff --git a/crates/jp_tool/src/definition.rs b/crates/jp_tool/src/definition.rs new file mode 100644 index 000000000..d720e3a74 --- /dev/null +++ b/crates/jp_tool/src/definition.rs @@ -0,0 +1,309 @@ +//! What a tool is called, what it accepts, and how it is described. +//! +//! A [`ToolDefinition`] is the resolved description of one tool, whatever its +//! source: a local command, a built-in implementation, or a tool a configured +//! MCP server declares. +//! Definition resolution lives in `jp_mcp::server`; this module holds the +//! resolved shape and the argument handling that reads its schema. + +use indexmap::IndexMap; +use serde_json::{Map, Value}; + +use crate::{Error, schema::Node}; + +/// Documentation for a single tool parameter. +#[derive(Debug, Clone)] +pub struct ParameterDocs { + /// Short description included in the provider's parameter schema. + pub summary: Option, + /// Expanded documentation returned by tool discovery. + pub description: Option, + /// Usage examples supplied by the tool configuration. + pub examples: Option, +} + +impl ParameterDocs { + /// Whether expanded documentation is absent, irrespective of the summary. + #[must_use] + pub fn is_empty(&self) -> bool { + self.description.is_none() && self.examples.is_none() + } +} + +/// Documentation for a single tool. +#[derive(Debug, Clone, Default)] +pub struct ToolDocs { + /// Short description included in the provider's tool schema. + pub summary: Option, + /// Expanded tool documentation. + pub description: Option, + /// Usage examples supplied by the tool configuration. + pub examples: Option, + /// Parameter documentation in declaration order. + pub parameters: IndexMap, +} + +impl ToolDocs { + /// Whether expanded tool and parameter documentation is absent. + #[must_use] + pub fn is_empty(&self) -> bool { + self.description.is_none() + && self.examples.is_none() + && self.parameters.values().all(ParameterDocs::is_empty) + } + + /// The short description used for the tool schema sent to the LLM. + /// + /// Returns `summary` if set, otherwise falls back to `description`. + #[must_use] + pub fn schema_description(&self) -> Option<&str> { + self.summary.as_deref().or(self.description.as_deref()) + } +} + +/// The definition of a tool. +#[derive(Debug, Clone)] +pub struct ToolDefinition { + /// Advertised name, which may differ from the upstream implementation name. + pub name: String, + /// Descriptions used by providers and tool discovery. + pub docs: ToolDocs, + + /// JSON Schema for the tool's arguments, as its source declared it, with + /// configuration overrides applied. + /// + /// Adapting this to what a given API accepts belongs to that provider. + pub parameters: Value, +} + +impl ToolDefinition { + /// Coerce JSON-encoded argument strings to non-string schema types. + /// + /// Strings stay unchanged when the schema accepts strings or their contents + /// do not parse to a declared type. + pub fn coerce_arguments(&self, arguments: &mut Map) { + coerce_arguments_to_schema(arguments, &self.parameters); + } + + /// Return the JSON Schema for the tool's parameters. + #[must_use] + pub fn to_parameters_schema(&self) -> Value { + self.parameters.clone() + } +} + +/// Split a description string into a short summary and remaining detail. +/// +/// If the text is short (single line, ≤120 chars), it is returned as the +/// summary with no remaining description. +/// +/// Otherwise, the first sentence is extracted as the summary. +/// A sentence ends at ` . ` or `.\n`. +/// The remainder becomes the description. +#[must_use] +pub fn split_description(text: &str) -> (String, Option) { + let text = text.trim(); + + // Find the first sentence boundary. + // Look for ". " or ".\n" — a period followed by whitespace. + for (i, _) in text.match_indices('.') { + let after = i + 1; + if after >= text.len() { + // Period at end of string — the whole text is one sentence. + break; + } + + let next_byte = text.as_bytes()[after]; + if next_byte == b'\n' { + // Period followed by newline is always a sentence boundary. + } else if next_byte == b' ' { + // Period followed by space: only split if the next non-space + // character is uppercase (heuristic to skip abbreviations + // like "e.g. foo"). + let rest_after_space = text[after..].trim_start(); + if rest_after_space.is_empty() + || !rest_after_space + .chars() + .next() + .is_some_and(char::is_uppercase) + { + continue; + } + } else { + continue; + } + + { + let summary = text[..=i].trim().to_owned(); + let rest = text[after..].trim(); + + if rest.is_empty() { + return (summary, None); + } + + return (summary, Some(rest.to_owned())); + } + } + + // No sentence boundary found — take the first line. + if let Some(nl) = text.find('\n') { + let summary = text[..nl].trim().to_owned(); + let rest = text[nl..].trim(); + + if rest.is_empty() { + return (summary, None); + } + + return (summary, Some(rest.to_owned())); + } + + // Single long line, no period — return as-is. + (text.to_owned(), None) +} + +/// Coerce JSON-encoded argument strings to the types the schema declares. +fn coerce_arguments_to_schema(arguments: &mut Map, schema: &Value) { + coerce_object(arguments, &Node::root(schema)); +} + +fn coerce_object(arguments: &mut Map, node: &Node<'_>) { + for (name, property) in node.properties() { + if let Some(value) = arguments.get_mut(&name) { + coerce_value(value, &property); + } + } +} + +fn coerce_value(value: &mut Value, node: &Node<'_>) { + // Coercion repairs an argument the schema cannot take as written. A + // parameter that permits the string has nothing to repair, so parsing it + // would hand the tool a number or an object where the model sent text. + if let Value::String(raw) = &*value + && !node.permits(value) + && let Ok(parsed) = serde_json::from_str::(raw) + && node.permits(&parsed) + { + *value = parsed; + } + + match value { + Value::Object(arguments) => coerce_object(arguments, node), + Value::Array(values) => { + let Some(items) = node.items() else { + return; + }; + for value in values { + coerce_value(value, &items); + } + } + _ => {} + } +} + +/// Fill in configured default values for missing parameters. +/// +/// LLMs commonly omit parameters that have a `default` in the JSON schema, even +/// when those parameters are marked `required`. +/// This function patches the arguments map before validation so that such +/// omissions don't cause spurious "missing argument" errors and unnecessary LLM +/// retries. +pub fn apply_parameter_defaults(arguments: &mut Map, schema: &Value) { + apply_defaults_to(arguments, &Node::root(schema)); +} + +fn apply_defaults_to(arguments: &mut Map, node: &Node<'_>) { + for (name, property) in node.properties() { + if !arguments.contains_key(&name) { + if let Some(default) = property.default() { + let default = default.clone(); + arguments.insert(name, default); + } + continue; + } + + // Recurse into object fields. + if property.has_properties() + && let Some(object) = arguments.get_mut(&name).and_then(Value::as_object_mut) + { + apply_defaults_to(object, &property); + } + + // Recurse into array elements. + if let Some(items) = property.items() + && items.has_properties() + && let Some(values) = arguments.get_mut(&name).and_then(Value::as_array_mut) + { + for value in values.iter_mut() { + if let Some(object) = value.as_object_mut() { + apply_defaults_to(object, &items); + } + } + } + } +} + +/// Check a call's arguments against the tool's parameters schema. +/// +/// # Errors +/// +/// Returns [`Error::Arguments`] naming every required argument that is absent +/// and every argument the schema does not declare. +pub fn validate_tool_arguments( + arguments: &Map, + schema: &Value, +) -> Result<(), Error> { + validate_arguments_against(arguments, &Node::root(schema)) +} + +fn validate_arguments_against( + arguments: &Map, + node: &Node<'_>, +) -> Result<(), Error> { + let properties = node.properties(); + + let unknown = arguments + .keys() + .filter(|name| !properties.iter().any(|(known, _)| known == *name)) + .cloned() + .collect::>(); + + let missing = properties + .iter() + .filter(|(name, _)| node.is_required(name) && !arguments.contains_key(name)) + .map(|(name, _)| name.clone()) + .collect::>(); + + if !missing.is_empty() || !unknown.is_empty() { + return Err(Error::Arguments { missing, unknown }); + } + + // Recurse into nested structures. + for (name, property) in properties { + let Some(value) = arguments.get(&name) else { + continue; + }; + + if let Some(object) = value.as_object() + && property.has_properties() + { + validate_arguments_against(object, &property)?; + } + + if let Some(items) = property.items() + && items.has_properties() + && let Some(values) = value.as_array() + { + for value in values { + if let Some(object) = value.as_object() { + validate_arguments_against(object, &items)?; + } + } + } + } + + Ok(()) +} + +#[cfg(test)] +#[path = "definition_tests.rs"] +mod tests; diff --git a/crates/jp_tool/src/definition_tests.rs b/crates/jp_tool/src/definition_tests.rs new file mode 100644 index 000000000..9ab33ea41 --- /dev/null +++ b/crates/jp_tool/src/definition_tests.rs @@ -0,0 +1,527 @@ +use serde_json::json; + +use super::*; + +/// Build a parameters schema from `(name, node, required)` triples. +fn schema(properties: [(&str, Value, bool); N]) -> Value { + let required = properties + .iter() + .filter(|(_, _, required)| *required) + .map(|(name, _, _)| Value::String((*name).to_owned())) + .collect::>(); + let properties = properties + .into_iter() + .map(|(name, node, _)| (name.to_owned(), node)) + .collect::>(); + + json!({ "type": "object", "properties": properties, "required": required }) +} + +/// A schema node of the given type. +fn param(kind: &str) -> Value { + json!({ "type": kind }) +} + +/// A schema node of the given type, carrying a default value. +fn param_with_default(kind: &str, default: &Value) -> Value { + json!({ "type": kind, "default": default }) +} + +fn definition(parameters: Value) -> ToolDefinition { + ToolDefinition { + name: "test".to_owned(), + docs: ToolDocs::default(), + parameters, + } +} + +/// Assert that validation reported exactly these missing and unknown arguments, +/// in this order. +#[track_caller] +fn assert_arguments_error(result: Result<(), Error>, missing: &[String], unknown: &[String]) { + let Err(Error::Arguments { + missing: got_missing, + unknown: got_unknown, + }) = result + else { + panic!("expected an argument error, got {result:?}") + }; + assert_eq!(got_missing, missing, "missing arguments"); + assert_eq!(got_unknown, unknown, "unknown arguments"); +} + +#[test] +fn coerces_json_strings_to_declared_parameter_types() { + let parameters = schema([ + ("path", param("string"), true), + ("start_line", param("integer"), false), + ("enabled", param("boolean"), false), + ( + "string_or_integer", + json!({ "type": ["string", "integer"] }), + false, + ), + ( + "patterns", + json!({ + "type": "array", + "items": { + "type": "object", + "properties": { "count": { "type": "integer" } }, + "required": ["count"] + } + }), + false, + ), + ]); + let mut arguments = json!({ + "path": "README.md", + "start_line": "1", + "enabled": "true", + "string_or_integer": "3", + "patterns": "[{\"count\":\"2\"}]" + }) + .as_object() + .cloned() + .unwrap(); + + definition(parameters).coerce_arguments(&mut arguments); + + assert_eq!( + Value::Object(arguments), + json!({ + "path": "README.md", + "start_line": 1, + "enabled": true, + "string_or_integer": "3", + "patterns": [{"count": 2}] + }) + ); +} + +/// Coercion repairs a string the schema cannot accept. +/// A parameter that declares no type accepts the string as written, so a +/// JSON-looking string reaches the tool as the text the model sent. +#[test] +fn leaves_strings_alone_for_a_parameter_with_no_declared_type() { + let parameters = schema([("value", json!({ "description": "Any JSON value." }), false)]); + let mut arguments = json!({ "value": "3" }).as_object().cloned().unwrap(); + + definition(parameters).coerce_arguments(&mut arguments); + + assert_eq!(Value::Object(arguments), json!({ "value": "3" })); +} + +/// A property with an `enum` and no `type` still says what it takes: the string +/// the model sent is not a member, and the number it parses to is. +#[test] +fn coerces_a_string_the_enum_excludes_into_the_member_it_parses_to() { + let parameters = schema([("value", json!({ "enum": [3] }), false)]); + let mut arguments = json!({ "value": "3" }).as_object().cloned().unwrap(); + + definition(parameters).coerce_arguments(&mut arguments); + + assert_eq!(Value::Object(arguments), json!({ "value": 3 })); +} + +/// The mirror case: the enum lists the string itself, so parsing it would +/// produce the one value the schema forbids. +#[test] +fn leaves_a_string_alone_when_the_enum_lists_it() { + let parameters = schema([("value", json!({ "enum": ["3"] }), false)]); + let mut arguments = json!({ "value": "3" }).as_object().cloned().unwrap(); + + definition(parameters).coerce_arguments(&mut arguments); + + assert_eq!(Value::Object(arguments), json!({ "value": "3" })); +} + +#[test] +fn test_validate_tool_arguments() { + struct TestCase { + arguments: Map, + parameters: Value, + /// The arguments reported missing and unknown, or `None` when the call + /// is expected to validate. + want: Option<(Vec, Vec)>, + } + + let cases = vec![ + ("empty", TestCase { + arguments: Map::new(), + parameters: schema([]), + want: None, + }), + ("correct", TestCase { + arguments: Map::from_iter([("foo".to_owned(), json!("bar"))]), + parameters: schema([ + ("foo", param("string"), true), + ("bar", param("string"), false), + ]), + want: None, + }), + ("missing", TestCase { + arguments: Map::new(), + parameters: schema([("foo", param("string"), true)]), + want: Some((vec!["foo".to_owned()], vec![])), + }), + ("unknown", TestCase { + arguments: Map::from_iter([("foo".to_owned(), json!("bar"))]), + parameters: schema([("bar", param("string"), false)]), + want: Some((vec![], vec!["foo".to_owned()])), + }), + ("both", TestCase { + arguments: Map::from_iter([("foo".to_owned(), json!("bar"))]), + parameters: schema([("bar", param("string"), true)]), + want: Some((vec!["bar".to_owned()], vec!["foo".to_owned()])), + }), + ]; + + for (name, test_case) in cases { + let result = validate_tool_arguments(&test_case.arguments, &test_case.parameters); + match test_case.want { + None => result.unwrap_or_else(|error| panic!("case {name} should validate: {error}")), + Some((missing, unknown)) => assert_arguments_error(result, &missing, &unknown), + } + } +} + +#[test] +fn test_validate_nested_array_item_properties() { + // Mirrors the fs_modify_file schema: + // patterns: array of { old: string (required), new: string (required) } + let parameters = schema([ + ("path", param("string"), true), + ( + "patterns", + json!({ + "type": "array", + "items": { + "type": "object", + "properties": { + "old": { "type": "string" }, + "new": { "type": "string" } + }, + "required": ["old", "new"] + } + }), + true, + ), + ]); + + // Valid: correct inner fields. + let args = json!({ + "path": "src/lib.rs", + "patterns": [{"old": "foo", "new": "bar"}] + }); + validate_tool_arguments(args.as_object().unwrap(), ¶meters).expect("arguments validate"); + + // Valid: multiple items. + let args = json!({ + "path": "src/lib.rs", + "patterns": [ + {"old": "a", "new": "b"}, + {"old": "c", "new": "d"} + ] + }); + validate_tool_arguments(args.as_object().unwrap(), ¶meters).expect("arguments validate"); + + // Invalid: unknown inner field. + let args = json!({ + "path": "src/lib.rs", + "patterns": [{"old": "foo", "new": "bar", "extra": true}] + }); + assert_arguments_error( + validate_tool_arguments(args.as_object().unwrap(), ¶meters), + &[], + &["extra".to_owned()], + ); + + // Invalid: missing required inner field. + let args = json!({ + "path": "src/lib.rs", + "patterns": [{"old": "foo"}] + }); + assert_arguments_error( + validate_tool_arguments(args.as_object().unwrap(), ¶meters), + &["new".to_owned()], + &[], + ); + + // Invalid: wrong inner field names (the LLM hallucinated names). + let args = json!({ + "path": "src/lib.rs", + "patterns": [{"string_to_replace": "foo", "new_string": "bar"}] + }); + let err = validate_tool_arguments(args.as_object().unwrap(), ¶meters); + assert!(err.is_err()); + let Error::Arguments { missing, unknown } = err.unwrap_err() else { + panic!("expected Arguments error"); + }; + assert_eq!(missing, vec!["old".to_owned(), "new".to_owned()]); + // preserve_order: keys iterate in insertion order from json! macro + assert_eq!(unknown, vec![ + "string_to_replace".to_owned(), + "new_string".to_owned() + ]); + + // Valid: non-object array items are skipped (no crash). + let args = json!({ + "path": "src/lib.rs", + "patterns": ["not an object"] + }); + validate_tool_arguments(args.as_object().unwrap(), ¶meters).expect("arguments validate"); + + // Valid: parameter is not an array (type mismatch, but not our job to check types). + let args = json!({ + "path": "src/lib.rs", + "patterns": "not an array" + }); + validate_tool_arguments(args.as_object().unwrap(), ¶meters).expect("arguments validate"); +} + +#[test] +fn test_validate_nested_object_properties() { + let parameters = schema([ + ("name", param("string"), true), + ( + "config", + json!({ + "type": "object", + "properties": { + "verbose": { "type": "boolean" }, + "output": { "type": "string" } + }, + "required": ["output"] + }), + false, + ), + ]); + + // Valid. + let args = json!({ "name": "test", "config": { "verbose": true, "output": "out.txt" } }); + validate_tool_arguments(args.as_object().unwrap(), ¶meters).expect("arguments validate"); + + // Valid: optional object param omitted entirely. + let args = json!({ "name": "test" }); + validate_tool_arguments(args.as_object().unwrap(), ¶meters).expect("arguments validate"); + + // Invalid: unknown field inside the object. + let args = json!({ "name": "test", "config": { "output": "o", "bogus": 1 } }); + assert_arguments_error( + validate_tool_arguments(args.as_object().unwrap(), ¶meters), + &[], + &["bogus".to_owned()], + ); + + // Invalid: missing required field inside the object. + let args = json!({ "name": "test", "config": { "verbose": true } }); + assert_arguments_error( + validate_tool_arguments(args.as_object().unwrap(), ¶meters), + &["output".to_owned()], + &[], + ); +} + +#[test] +fn test_apply_defaults_fills_missing_required_with_default() { + let parameters = schema([ + ("path", param("string"), true), + ( + "use_regex", + param_with_default("boolean", &json!(false)), + true, + ), + ]); + + let mut args: Map = Map::from_iter([("path".to_owned(), json!("src/lib.rs"))]); + + apply_parameter_defaults(&mut args, ¶meters); + + assert_eq!(args.get("path"), Some(&json!("src/lib.rs"))); + assert_eq!(args.get("use_regex"), Some(&json!(false))); +} + +#[test] +fn test_apply_defaults_does_not_overwrite_provided_values() { + let parameters = schema([( + "use_regex", + param_with_default("boolean", &json!(false)), + true, + )]); + + let mut args: Map = Map::from_iter([("use_regex".to_owned(), json!(true))]); + + apply_parameter_defaults(&mut args, ¶meters); + + assert_eq!(args.get("use_regex"), Some(&json!(true))); +} + +#[test] +fn test_apply_defaults_fills_optional_param_with_default() { + let parameters = schema([( + "verbose", + param_with_default("boolean", &json!(false)), + false, + )]); + + let mut args: Map = Map::new(); + apply_parameter_defaults(&mut args, ¶meters); + + assert_eq!(args.get("verbose"), Some(&json!(false))); +} + +#[test] +fn test_apply_defaults_skips_params_without_default() { + let parameters = schema([("path", param("string"), true)]); + + let mut args: Map = Map::new(); + apply_parameter_defaults(&mut args, ¶meters); + + assert!(!args.contains_key("path")); +} + +#[test] +fn test_apply_defaults_recurses_into_objects() { + let parameters = schema([( + "config", + json!({ + "type": "object", + "properties": { "verbose": { "type": "boolean", "default": true } } + }), + false, + )]); + + let mut args: Map = Map::from_iter([("config".to_owned(), json!({}))]); + + apply_parameter_defaults(&mut args, ¶meters); + + assert_eq!(args["config"]["verbose"], json!(true)); +} + +#[test] +fn test_apply_defaults_recurses_into_array_items() { + let parameters = schema([( + "items", + json!({ + "type": "array", + "items": { + "type": "object", + "properties": { "enabled": { "type": "boolean", "default": true } } + } + }), + true, + )]); + + let mut args: Map = Map::from_iter([( + "items".to_owned(), + json!([{"name": "a"}, {"name": "b", "enabled": false}]), + )]); + + apply_parameter_defaults(&mut args, ¶meters); + + let items = args["items"].as_array().unwrap(); + assert_eq!(items[0]["enabled"], json!(true)); + // Explicitly provided false is preserved. + assert_eq!(items[1]["enabled"], json!(false)); +} + +#[test] +fn test_apply_defaults_then_validate_passes() { + // Mirrors the fs_modify_file scenario: replace_using_regex is required + // with a default, and the LLM omits it. + let parameters = schema([ + ("path", param("string"), true), + ( + "replace_using_regex", + param_with_default("boolean", &json!(false)), + true, + ), + ]); + + let mut args: Map = Map::from_iter([("path".to_owned(), json!("README.md"))]); + + // Without defaults, validation would fail. + assert!(validate_tool_arguments(&args, ¶meters).is_err()); + + // After applying defaults, validation passes. + apply_parameter_defaults(&mut args, ¶meters); + assert!(validate_tool_arguments(&args, ¶meters).is_ok()); + assert_eq!(args["replace_using_regex"], json!(false)); +} + +#[test] +fn test_split_short_single_line() { + let (s, d) = split_description("Run cargo check."); + assert_eq!(s, "Run cargo check."); + assert_eq!(d, None); +} + +#[test] +fn test_split_short_no_period() { + let (s, d) = split_description("Run cargo check"); + assert_eq!(s, "Run cargo check"); + assert_eq!(d, None); +} + +#[test] +fn test_split_two_sentences() { + let (s, d) = split_description( + "Run cargo check on a package. Supports workspace packages and feature flags.", + ); + assert_eq!(s, "Run cargo check on a package."); + assert_eq!( + d, + Some("Supports workspace packages and feature flags.".to_owned()) + ); +} + +#[test] +fn test_split_multiline() { + let input = "Search for code in a repository.\n\nSupports regex and qualifiers."; + let (s, d) = split_description(input); + assert_eq!(s, "Search for code in a repository."); + assert_eq!(d, Some("Supports regex and qualifiers.".to_owned())); +} + +#[test] +fn test_split_multiline_no_period() { + let input = "First line without period\nSecond line here."; + let (s, d) = split_description(input); + assert_eq!(s, "First line without period"); + assert_eq!(d, Some("Second line here.".to_owned())); +} + +#[test] +fn test_split_preserves_abbreviations() { + // "e.g." should not be treated as a sentence boundary. + let (s, d) = split_description("Use e.g. foo or bar."); + assert_eq!(s, "Use e.g. foo or bar."); + assert_eq!(d, None); +} + +#[test] +fn test_split_long_single_line_with_period() { + let input = "This is a very long description that exceeds the threshold. It contains \ + additional details about the tool's behavior."; + let (s, d) = split_description(input); + assert_eq!( + s, + "This is a very long description that exceeds the threshold." + ); + assert!(d.is_some()); +} + +#[test] +fn test_split_empty() { + let (s, d) = split_description(""); + assert_eq!(s, ""); + assert_eq!(d, None); +} + +#[test] +fn test_split_trims_whitespace() { + let (s, d) = split_description(" hello "); + assert_eq!(s, "hello"); + assert_eq!(d, None); +} diff --git a/crates/jp_tool/src/error.rs b/crates/jp_tool/src/error.rs new file mode 100644 index 000000000..e941769f6 --- /dev/null +++ b/crates/jp_tool/src/error.rs @@ -0,0 +1,59 @@ +/// A failure in the tool domain: resolving a tool, reading its parameter +/// schema, checking the arguments a call carries, or running the tool itself. +/// +/// A tool that ran and reported a problem of its own is not an `Error`: that is +/// [`Outcome::Error`], which the caller hands back to the model. +/// +/// [`Outcome::Error`]: crate::Outcome::Error +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// A recognized tool-result envelope is malformed. + #[error("Malformed tool output: {0}")] + MalformedOutput(#[source] serde_json::Error), + + #[error("Tool not found: {name}")] + NotFound { name: String }, + + #[error("Tools not found: {}", names.join(", "))] + NotFoundN { names: Vec }, + + #[error("Command missing for local tool")] + MissingCommand, + + /// Wraps the MCP client's own error, which this crate does not name so it + /// stays independent of the MCP implementation. + #[error("Failed to fetch tool from MCP client")] + McpGetToolError(#[source] Box), + + /// Wraps the MCP client's own error, which this crate does not name so it + /// stays independent of the MCP implementation. + #[error("Failed to run tool from MCP client")] + McpRunToolError(#[source] Box), + + #[error("Failed to spawn command: {command}")] + SpawnError { + command: String, + #[source] + error: std::io::Error, + }, + + /// `data` is the template that failed to render, kept for the diagnostic. + #[error("Template error")] + TemplateError { + data: String, + #[source] + error: Box, + }, + + #[error("Invalid schema at `{path}`: {message}")] + InvalidSchema { path: String, message: String }, + + #[error("Invalid arguments (missing: {missing:?}, unknown: {unknown:?})")] + Arguments { + /// Required arguments that were missing. + missing: Vec, + + /// Unknown arguments that were provided. + unknown: Vec, + }, +} diff --git a/crates/jp_tool/src/lib.rs b/crates/jp_tool/src/lib.rs index 6a884b293..d7652c17c 100644 --- a/crates/jp_tool/src/lib.rs +++ b/crates/jp_tool/src/lib.rs @@ -5,10 +5,32 @@ use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; mod access; +pub mod content; +pub mod definition; +mod error; +pub mod schema; + pub use access::{ AccessPolicy, Capability, EnvRule, FsAccessError, FsRule, NetRule, canonicalize_workspace_target, lexical_workspace_relative, }; +pub use content::{ContentBlock, InputRequest, Resource, ResourceContent, ToolResult}; +pub use definition::{ParameterDocs, ToolDefinition, ToolDocs}; +pub use error::Error; + +/// Which workspace and conversation a tool call belongs to. +/// +/// Surfaced to local tools through the rendered template `context` (as +/// `context.workspace_id` and `context.conversation_id`) so a tool can scope +/// any state it persists to the conversation that caused it. +/// +/// Both fields are empty for a call with no conversation owner, such as a title +/// generation or a summary. +#[derive(Debug, Clone, Default)] +pub struct InvocationContext { + pub workspace_id: String, + pub conversation_id: String, +} /// The result of a tool call. #[derive(Debug, PartialEq, Serialize, Deserialize)] @@ -83,6 +105,42 @@ impl Outcome { pub fn unwrap_content(self) -> String { self.into_content().unwrap() } + + /// Whether `text` claims to be a `needs_input` outcome, however badly. + /// + /// Answers the question a decoder asks after [`Outcome`] itself failed to + /// parse: did the tool mean to ask something? + /// A payload that says it did and then will not parse is a protocol + /// mismatch the caller must report, where output that was never an + /// `Outcome` is just text. + #[must_use] + pub fn claims_needs_input(text: &str) -> bool { + Self::claimed_shape(text).is_some_and(|kind| kind == "needs_input") + } + + /// The variant tag `text` carries, if it is a JSON object carrying one. + fn claimed_shape(text: &str) -> Option { + serde_json::from_str::(text) + .ok()? + .get("type")? + .as_str() + .map(str::to_owned) + } + + /// The question id a `needs_input` payload carries, if it carries one. + /// + /// Read straight from the JSON rather than from a parsed [`Question`], + /// because the reason a caller wants it is that parsing failed: an id that + /// [`QuestionId`] rejects is exactly what it is looking for. + #[must_use] + pub fn claimed_question_id(text: &str) -> Option { + serde_json::from_str::(text) + .ok()? + .get("question")? + .get("id")? + .as_str() + .map(str::to_owned) + } } /// A validated tool-question identifier. @@ -206,16 +264,26 @@ pub struct Question { } impl Question { - /// Create a new text question. - /// Fails if `id` is empty or contains a `.`. - pub fn text(id: impl Into, text: impl Into) -> Result { - Ok(Self { - id: QuestionId::try_from(id.into())?, + /// Construct a question with an already validated identifier. + #[must_use] + pub fn new(id: QuestionId, text: impl Into, answer_type: AnswerType) -> Self { + Self { + id, text: text.into(), + answer_type, pre_amble: None, - answer_type: AnswerType::Text, default: None, - }) + } + } + + /// Create a new text question. + /// Fails if `id` is empty or contains a `.`. + pub fn text(id: impl Into, text: impl Into) -> Result { + Ok(Self::new( + QuestionId::try_from(id.into())?, + text, + AnswerType::Text, + )) } /// Create a new boolean question. diff --git a/crates/jp_llm/src/tool/json_schema.rs b/crates/jp_tool/src/schema.rs similarity index 66% rename from crates/jp_llm/src/tool/json_schema.rs rename to crates/jp_tool/src/schema.rs index 042210dff..cd7d3a7bc 100644 --- a/crates/jp_llm/src/tool/json_schema.rs +++ b/crates/jp_tool/src/schema.rs @@ -1,4 +1,4 @@ -//! JSON Schema for tool parameters: construction, validation, and reading. +//! Reading and validating a tool's parameter schema. //! //! A tool's parameters are one JSON Schema object, held exactly as its source //! declared it. @@ -11,14 +11,15 @@ //! [`Node`] is the read-only view used by argument handling and validation. //! It follows same-document `$ref` pointers while reading, so a referenced enum //! or nested object answers questions the same way an inline one does. +//! +//! Building a schema from configuration lives with the configuration types; +//! this module only reads and checks one that already exists. use std::borrow::Cow; -use indexmap::IndexMap; -use jp_config::conversation::tool::{OneOrManyTypes, ToolParameterConfig}; -use serde_json::{Map, Value, json}; +use serde_json::{Map, Value}; -use crate::error::ToolError; +use crate::Error; /// JSON types a tool parameter may declare. const SUPPORTED_TYPES: &[&str] = &[ @@ -29,82 +30,17 @@ const SUPPORTED_TYPES: &[&str] = &[ /// terminates. const MAX_REF_HOPS: usize = 32; -/// Build the parameters schema for a tool whose shape is defined entirely in -/// configuration. -/// -/// Local and built-in tools have no upstream schema, so every parameter must -/// declare a type. -pub fn from_config( - path: &str, - parameters: &IndexMap, -) -> Result { - let mut properties = Map::new(); - let mut required = vec![]; - - for (name, parameter) in parameters { - let node = node_from_config(&format!("{path}.{name}"), parameter)?; - if parameter.required.unwrap_or(false) { - required.push(Value::String(name.clone())); - } - properties.insert(name.clone(), node); - } - - Ok(object_schema(properties, required)) -} - -/// Apply configured overrides to a schema declared by an MCP server. -/// -/// The server's document is preserved, including any `$defs` block. -/// An override may narrow a parameter, but may not contradict the type the -/// server declared. -pub fn with_overrides( - path: &str, - source: &Value, - overrides: &IndexMap, -) -> Result { - let mut schema = source.as_object().cloned().unwrap_or_default(); - let source_required = required_names(source); - - let mut properties = source - .get("properties") - .and_then(Value::as_object) - .cloned() - .unwrap_or_default(); - let mut required = source_required - .iter() - .map(|name| Value::String((*name).to_owned())) - .collect::>(); - - for (name, override_config) in overrides { - let path = format!("{path}.{name}"); - let node = match properties.get(name) { - Some(node) => node_with_override(&path, node, source, override_config)?, - None => node_from_config(&path, override_config)?, - }; - properties.insert(name.clone(), node); - - // A server's requirement cannot be relaxed, only added to: dropping it - // would produce calls that omit an argument the server expects. - let named = Value::String(name.clone()); - if override_config.required == Some(true) && !required.contains(&named) { - required.push(named); - } - } - - schema.insert("properties".to_owned(), Value::Object(properties)); - schema.insert("required".to_owned(), Value::Array(required)); - schema.insert("type".to_owned(), Value::String("object".to_owned())); - - Ok(Value::Object(schema)) -} - /// Validate a tool's parameters schema. /// /// Rejects the shapes that no provider can act on, and the ones that contradict /// themselves: unusable types, arrays with no item schema, `items` or /// `properties` on a type that cannot carry them, duplicate or ill-typed enum /// values, and defaults the schema itself forbids. -pub fn validate(path: &str, schema: &Value) -> Result<(), ToolError> { +/// +/// # Errors +/// +/// Returns [`Error::InvalidSchema`], naming the offending path. +pub fn validate(path: &str, schema: &Value) -> Result<(), Error> { let root = Node::root(schema); for (name, property) in root.properties() { validate_node(&format!("{path}.{name}"), &property, &mut vec![])?; @@ -118,7 +54,7 @@ pub fn validate(path: &str, schema: &Value) -> Result<(), ToolError> { /// A recursive schema is legal, and providers that reject it say so themselves. /// Re-entering a definition already on the path adds nothing, so the walk stops /// there instead of expanding forever. -fn validate_node(path: &str, node: &Node<'_>, visiting: &mut Vec) -> Result<(), ToolError> { +fn validate_node(path: &str, node: &Node<'_>, visiting: &mut Vec) -> Result<(), Error> { if let Some(origin) = node.origin() { if visiting.iter().any(|seen| seen == origin) { return Ok(()); @@ -139,7 +75,7 @@ fn validate_node_inner( path: &str, node: &Node<'_>, visiting: &mut Vec, -) -> Result<(), ToolError> { +) -> Result<(), Error> { let types = node.types(); // A schema with no `type` keyword accepts any value, and constrains // nothing that could contradict its `items`, `properties`, `enum` or @@ -152,7 +88,7 @@ fn validate_node_inner( let items = node.items(); if types.iter().any(|type_| type_ == "array") && items.is_none() { - return Err(ToolError::InvalidSchema { + return Err(Error::InvalidSchema { path: format!("{path}.items"), message: "array schemas must declare an item schema".to_owned(), }); @@ -160,7 +96,7 @@ fn validate_node_inner( if let Some(items) = &items { if !unconstrained && !types.iter().any(|type_| type_ == "array") { - return Err(ToolError::InvalidSchema { + return Err(Error::InvalidSchema { path: format!("{path}.items"), message: format!( "`items` requires an array type, but the schema requires {}", @@ -173,7 +109,7 @@ fn validate_node_inner( let properties = node.properties(); if !unconstrained && !properties.is_empty() && !types.iter().any(|type_| type_ == "object") { - return Err(ToolError::InvalidSchema { + return Err(Error::InvalidSchema { path: format!("{path}.properties"), message: format!( "`properties` requires an object type, but the schema requires {}", @@ -188,7 +124,7 @@ fn validate_node_inner( let enumeration = node.enumeration(); for (index, value) in enumeration.iter().enumerate() { if enumeration[..index].contains(value) { - return Err(ToolError::InvalidSchema { + return Err(Error::InvalidSchema { path: format!("{path}.enum"), message: format!("enum values must be unique; duplicate value {value}"), }); @@ -204,7 +140,7 @@ fn validate_node_inner( } else { String::new() }; - return Err(ToolError::InvalidSchema { + return Err(Error::InvalidSchema { path: format!("{path}.enum"), message: format!( "enum value {value} has type {}, but the schema requires {}{hint}", @@ -227,14 +163,9 @@ fn validate_node_inner( /// object properties so nested constraints are enforced at every depth. /// `subject` names what is being checked (`default value`, `enum value`) for /// the error message. -fn validate_value( - path: &str, - value: &Value, - node: &Node<'_>, - subject: &str, -) -> Result<(), ToolError> { +fn validate_value(path: &str, value: &Value, node: &Node<'_>, subject: &str) -> Result<(), Error> { if !node.accepts_type(value) { - return Err(ToolError::InvalidSchema { + return Err(Error::InvalidSchema { path: path.to_owned(), message: format!( "{subject} {value} has type {}, but the schema requires {}", @@ -246,7 +177,7 @@ fn validate_value( let enumeration = node.enumeration(); if !enumeration.is_empty() && !enumeration.contains(value) { - return Err(ToolError::InvalidSchema { + return Err(Error::InvalidSchema { path: path.to_owned(), message: format!("{subject} {value} is not allowed by the enum"), }); @@ -262,7 +193,7 @@ fn validate_value( for (name, property) in node.properties() { let Some(value) = values.get(&name) else { if node.is_required(&name) { - return Err(ToolError::InvalidSchema { + return Err(Error::InvalidSchema { path: format!("{path}.{name}"), message: format!("{subject} is missing required property `{name}`"), }); @@ -276,9 +207,15 @@ fn validate_value( Ok(()) } -fn validate_types(path: &str, types: &[String]) -> Result<(), ToolError> { +/// Check that a type declaration names usable, non-repeating JSON types. +/// +/// # Errors +/// +/// Returns [`Error::InvalidSchema`] for an empty, unsupported, or duplicated +/// type. +pub fn validate_types(path: &str, types: &[String]) -> Result<(), Error> { if types.is_empty() { - return Err(ToolError::InvalidSchema { + return Err(Error::InvalidSchema { path: format!("{path}.type"), message: "schema does not declare a supported type".to_owned(), }); @@ -286,13 +223,13 @@ fn validate_types(path: &str, types: &[String]) -> Result<(), ToolError> { for (index, type_) in types.iter().enumerate() { if !SUPPORTED_TYPES.contains(&type_.as_str()) { - return Err(ToolError::InvalidSchema { + return Err(Error::InvalidSchema { path: format!("{path}.type"), message: format!("unsupported JSON type `{type_}`"), }); } if types[..index].contains(type_) { - return Err(ToolError::InvalidSchema { + return Err(Error::InvalidSchema { path: format!("{path}.type"), message: format!("type values must be unique; duplicate type `{type_}`"), }); @@ -424,7 +361,8 @@ impl<'a> Node<'a> { /// /// The node is cloned because resolving a `$ref` produces a new value that /// cannot borrow from the parent. - fn child(&self, node: &Value) -> Node<'a> { + #[must_use] + pub fn child(&self, node: &Value) -> Node<'a> { Node { root: self.root, node: Cow::Owned(resolve(node, self.root).into_owned()), @@ -627,7 +565,9 @@ fn follow_pointer(pointer: &str, root: &Value) -> Option> { current.as_object().cloned() } -fn required_names(schema: &Value) -> Vec<&str> { +/// The property names a schema object lists as required. +#[must_use] +pub fn required_names(schema: &Value) -> Vec<&str> { schema .get("required") .and_then(Value::as_array) @@ -637,168 +577,6 @@ fn required_names(schema: &Value) -> Vec<&str> { .collect() } -fn object_schema(properties: Map, required: Vec) -> Value { - json!({ - "type": "object", - "properties": Value::Object(properties), - "required": Value::Array(required), - }) -} - -/// Build one schema node from configuration alone. -fn node_from_config(path: &str, config: &ToolParameterConfig) -> Result { - let kind = config - .kind - .as_ref() - .ok_or_else(|| ToolError::InvalidSchema { - path: format!("{path}.type"), - message: "local and built-in tool parameters must declare a type".to_owned(), - })?; - - let mut node = Map::new(); - node.insert("type".to_owned(), types_to_json(kind)); - apply_config_fields(path, &mut node, &Value::Null, config)?; - - Ok(Value::Object(node)) -} - -/// Overlay configuration onto a node the source already declared. -fn node_with_override( - path: &str, - source: &Value, - root: &Value, - config: &ToolParameterConfig, -) -> Result { - let mut node = source.as_object().cloned().unwrap_or_default(); - - if let Some(kind) = &config.kind { - // The source keeps its own declaration; an override may restate it but - // not contradict it, since the source owns the contract. Resolving - // against the document is what lets a referenced type be compared. - let declared = Node::root(root).child(source).types(); - if !declared.is_empty() && !types_match(&declared, kind) { - return Err(ToolError::InvalidSchema { - path: format!("{path}.type"), - message: format!( - "MCP declares {}, but the configuration declares {}", - format_types(&declared), - format_types(&type_names(kind)) - ), - }); - } - validate_types(path, &type_names(kind))?; - if declared.is_empty() { - node.insert("type".to_owned(), types_to_json(kind)); - } - } - - apply_config_fields(path, &mut node, root, config)?; - - Ok(Value::Object(node)) -} - -/// Apply the override fields shared by both construction paths. -/// -/// `root` is the document nested nodes resolve against; it is [`Value::Null`] -/// when the schema is built from configuration alone. -fn apply_config_fields( - path: &str, - node: &mut Map, - root: &Value, - config: &ToolParameterConfig, -) -> Result<(), ToolError> { - if let Some(default) = &config.default { - node.insert("default".to_owned(), default.clone()); - } - if let Some(enumeration) = &config.enumeration { - if enumeration.is_empty() { - node.remove("enum"); - } else { - node.insert("enum".to_owned(), Value::Array(enumeration.clone())); - } - } - if let Some(description) = config.summary.as_ref().or(config.description.as_ref()) { - let source = node.get("description").and_then(Value::as_str); - if let Some(merged) = merge_description(Some(description.clone()), source) { - node.insert("description".to_owned(), Value::String(merged)); - } - } - - if let Some(items) = config.items.as_deref() { - let path = format!("{path}.items"); - let merged = match node.get("items") { - Some(source) => node_with_override(&path, source, root, items)?, - None => node_from_config(&path, items)?, - }; - node.insert("items".to_owned(), merged); - } - - if !config.properties.is_empty() { - let mut properties = node - .get("properties") - .and_then(Value::as_object) - .cloned() - .unwrap_or_default(); - let mut required = node - .get("required") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - - for (name, property) in config.properties.iter() { - let path = format!("{path}.properties.{name}"); - let merged = match properties.get(name) { - Some(source) => node_with_override(&path, source, root, property)?, - None => node_from_config(&path, property)?, - }; - properties.insert(name.clone(), merged); - - let named = Value::String(name.clone()); - if property.required == Some(true) && !required.contains(&named) { - required.push(named); - } - } - - node.insert("properties".to_owned(), Value::Object(properties)); - if !required.is_empty() { - node.insert("required".to_owned(), Value::Array(required)); - } - } - - Ok(()) -} - -fn type_names(types: &OneOrManyTypes) -> Vec { - match types { - OneOrManyTypes::One(type_) => vec![type_.clone()], - OneOrManyTypes::Many(types) => types.clone(), - } -} - -fn types_to_json(types: &OneOrManyTypes) -> Value { - match types { - OneOrManyTypes::One(type_) => Value::String(type_.clone()), - OneOrManyTypes::Many(types) => { - Value::Array(types.iter().cloned().map(Value::String).collect()) - } - } -} - -/// Whether two type declarations describe the same set of JSON types. -/// -/// JSON Schema type arrays are unordered, and a single-element array means the -/// same thing as a bare string, so `["null", "string"]`, `["string", "null"]` -/// and `"string"` all compare equal. -fn types_match(left: &[String], right: &OneOrManyTypes) -> bool { - let normalize = |mut types: Vec| { - types.sort_unstable(); - types.dedup(); - types - }; - - normalize(left.to_vec()) == normalize(type_names(right)) -} - /// Merge a user-provided description with the one the source declared. /// /// A user description containing `{{description}}` has the source's text @@ -827,10 +605,12 @@ fn value_type(value: &Value) -> &'static str { } } -fn format_types(types: &[String]) -> String { +/// Render a type declaration for a diagnostic, as `string or null`. +#[must_use] +pub fn format_types(types: &[String]) -> String { types.join(" or ") } #[cfg(test)] -#[path = "json_schema_tests.rs"] +#[path = "schema_tests.rs"] mod tests; diff --git a/crates/jp_llm/src/tool/json_schema_tests.rs b/crates/jp_tool/src/schema_tests.rs similarity index 62% rename from crates/jp_llm/src/tool/json_schema_tests.rs rename to crates/jp_tool/src/schema_tests.rs index 6ff417510..639441ae9 100644 --- a/crates/jp_llm/src/tool/json_schema_tests.rs +++ b/crates/jp_tool/src/schema_tests.rs @@ -1,315 +1,11 @@ -use indexmap::IndexMap; -use jp_config::conversation::tool::ToolParameterConfig; use serde_json::json; use super::*; -/// Parse a parameter override the way a configuration file would produce it. -fn config(value: serde_json::Value) -> ToolParameterConfig { - serde_json::from_value(value).expect("valid parameter config") -} - -fn configs(values: &[(&str, serde_json::Value)]) -> IndexMap { - values - .iter() - .map(|(name, value)| ((*name).to_owned(), config(value.clone()))) - .collect() -} - -fn error_of(result: Result) -> String { - result.unwrap_err().to_string() -} - -mod from_config { - use super::*; - - #[test] - fn builds_an_object_schema() { - let parameters = configs(&[ - ("path", json!({ "type": "string", "required": true })), - ( - "limit", - json!({ "type": "integer", "default": 10, "summary": "How many." }), - ), - ]); - - let schema = from_config("tools.demo.parameters", ¶meters).unwrap(); - - assert_eq!( - schema, - json!({ - "type": "object", - "properties": { - "path": { "type": "string" }, - "limit": { "type": "integer", "default": 10, "description": "How many." } - }, - "required": ["path"] - }) - ); - } - - #[test] - fn builds_nested_arrays_and_objects() { - let parameters = configs(&[ - ( - "tags", - json!({ "type": "array", "items": { "type": "string", "enum": ["a", "b"] } }), - ), - ( - "target", - json!({ - "type": "object", - "properties": { "path": { "type": "string", "required": true } } - }), - ), - ]); - - let schema = from_config("tools.demo.parameters", ¶meters).unwrap(); - - assert_eq!( - schema, - json!({ - "type": "object", - "properties": { - "tags": { - "type": "array", - "items": { "type": "string", "enum": ["a", "b"] } - }, - "target": { - "type": "object", - "properties": { "path": { "type": "string" } }, - "required": ["path"] - } - }, - "required": [] - }) - ); - } - - #[test] - fn a_parameter_without_a_type_is_rejected() { - let parameters = configs(&[("path", json!({ "summary": "Where." }))]); - - assert_eq!( - error_of(from_config("tools.demo.parameters", ¶meters)), - "Invalid schema at `tools.demo.parameters.path.type`: local and built-in tool \ - parameters must declare a type" - ); - } -} - -mod with_overrides { - use super::*; - - /// The server's document is the source of truth: anything the override does - /// not speak to survives untouched, `$defs` included. - #[test] - fn preserves_the_server_document() { - let source = json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "title": "CreateNote", - "properties": { - "title": { "type": "string" }, - "tags": { "type": "array", "items": { "$ref": "#/$defs/Tag" } } - }, - "required": ["title"], - "$defs": { - "Tag": { "type": "string" } - } - }); - let overrides = configs(&[("tags", json!({ "items": { "enum": ["task", "idea"] } }))]); - - let schema = with_overrides("tools.notes.parameters", &source, &overrides).unwrap(); - - assert_eq!( - schema, - json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "title": "CreateNote", - "properties": { - "title": { "type": "string" }, - "tags": { - "type": "array", - "items": { "$ref": "#/$defs/Tag", "enum": ["task", "idea"] } - } - }, - "required": ["title"], - "$defs": { - "Tag": { "type": "string" } - } - }) - ); - } - - /// The reference stays a reference. - /// Narrowing it adds a sibling keyword rather than expanding the definition - /// into the document. - #[test] - fn a_referenced_item_keeps_its_reference() { - let source = json!({ - "type": "object", - "properties": { - "kinds": { "type": "array", "items": { "$ref": "#/$defs/EntryType" } } - }, - "$defs": { "EntryType": { "type": "string", "enum": ["Enum", "Method"] } } - }); - let overrides = configs(&[("kinds", json!({ "items": { "type": "string" } }))]); - - let schema = with_overrides("tools.docs.parameters", &source, &overrides).unwrap(); - - assert_eq!( - schema["properties"]["kinds"]["items"], - json!({ "$ref": "#/$defs/EntryType" }) - ); - } - - #[test] - fn an_empty_enum_clears_an_inherited_one() { - let source = json!({ - "type": "object", - "properties": { "state": { "type": "string", "enum": ["open", "closed"] } } - }); - let overrides = configs(&[("state", json!({ "enum": [] }))]); - - let schema = with_overrides("tools.demo.parameters", &source, &overrides).unwrap(); - - assert_eq!(schema["properties"]["state"], json!({ "type": "string" })); - } - - #[test] - fn a_contradicting_type_is_rejected() { - let source = json!({ - "type": "object", - "properties": { "count": { "type": "integer" } } - }); - let overrides = configs(&[("count", json!({ "type": "string" }))]); - - assert_eq!( - error_of(with_overrides("tools.demo.parameters", &source, &overrides)), - "Invalid schema at `tools.demo.parameters.count.type`: MCP declares integer, but the \ - configuration declares string" - ); - } - - /// A referenced type is compared through the document, so restating it - /// correctly is accepted and restating it wrongly is not. - #[test] - fn a_contradicting_type_is_rejected_through_a_reference() { - let source = json!({ - "type": "object", - "properties": { "kind": { "$ref": "#/$defs/Kind" } }, - "$defs": { "Kind": { "type": "string" } } - }); - let overrides = configs(&[("kind", json!({ "type": "integer" }))]); - - assert_eq!( - error_of(with_overrides("tools.demo.parameters", &source, &overrides)), - "Invalid schema at `tools.demo.parameters.kind.type`: MCP declares string, but the \ - configuration declares integer" - ); - } - - /// JSON Schema type arrays are unordered, and a single-element array means - /// the same as the bare string. - #[test] - fn a_matching_type_may_be_restated_in_any_form() { - let source = json!({ - "type": "object", - "properties": { - "content": { "type": ["string", "null"] }, - "name": { "type": "string" } - } - }); - let overrides = configs(&[ - ("content", json!({ "type": ["null", "string"] })), - ("name", json!({ "type": ["string"] })), - ]); - - let schema = with_overrides("tools.demo.parameters", &source, &overrides).unwrap(); - - assert_eq!( - schema["properties"]["content"]["type"], - json!(["string", "null"]) - ); - assert_eq!(schema["properties"]["name"]["type"], json!("string")); - } - - #[test] - fn required_can_be_tightened_but_not_loosened() { - let source = json!({ - "type": "object", - "properties": { "a": { "type": "string" }, "b": { "type": "string" } }, - "required": ["b"] - }); - let overrides = configs(&[ - ("a", json!({ "required": true })), - ("b", json!({ "required": false })), - ]); - - let schema = with_overrides("tools.demo.parameters", &source, &overrides).unwrap(); - - assert_eq!(schema["required"], json!(["b", "a"])); - } - - /// A property with no `type` is the server saying "any value". - /// The document keeps it as written and the tool stays usable. - #[test] - fn a_free_form_property_survives_and_validates() { - let source = json!({ - "type": "object", - "properties": { - "key": { "type": "string" }, - "value": { "description": "Any JSON value." } - } - }); - - let schema = with_overrides("tools.store.parameters", &source, &IndexMap::new()).unwrap(); - - assert_eq!( - schema["properties"]["value"], - json!({ "description": "Any JSON value." }) - ); - assert!(validate("tools.store.parameters", &schema).is_ok()); - } - - /// Nothing was declared, so nothing is contradicted: configuration may - /// narrow a free-form property to the shape the user actually wants. - #[test] - fn a_free_form_property_can_be_narrowed_by_configuration() { - let source = json!({ - "type": "object", - "properties": { "value": { "description": "Any JSON value." } } - }); - let overrides = configs(&[("value", json!({ "type": "object" }))]); - - let schema = with_overrides("tools.store.parameters", &source, &overrides).unwrap(); - - assert_eq!( - schema["properties"]["value"], - json!({ "type": "object", "description": "Any JSON value." }) - ); - } - - #[test] - fn a_property_the_server_omits_is_added() { - let source = json!({ "type": "object", "properties": {} }); - let overrides = configs(&[("extra", json!({ "type": "string", "summary": "Added." }))]); - - let schema = with_overrides("tools.demo.parameters", &source, &overrides).unwrap(); - - assert_eq!( - schema["properties"]["extra"], - json!({ "type": "string", "description": "Added." }) - ); - } -} - mod validate { use super::*; - fn validated(schema: &serde_json::Value) -> Result<(), ToolError> { + fn validated(schema: &serde_json::Value) -> Result<(), Error> { validate("tools.demo.parameters", schema) } diff --git a/docs/README/providers.md b/docs/README/providers.md index 280a4fca0..5839cf8be 100644 --- a/docs/README/providers.md +++ b/docs/README/providers.md @@ -19,6 +19,131 @@ situations, add model aliases, and start using newly released models without updating JP. No lock-in. +## Anthropic subscription flows + +`providers.llm.anthropic.subscription_flow` selects `acp` (the default) or +`direct` for subscription entries in the authentication chain. +API-key entries always use the existing HTTP implementation and require no +external runtime. +Model IDs and `--auth` syntax are unchanged. + +ACP compatibility checks require `claude-agent-acp` 0.76.0 with Claude Code +2.1.257 and an active Pro or Max login: + +```sh +npm install --global --prefix "$HOME/.local" --include=optional @agentclientprotocol/claude-agent-acp@0.76.0 +export PATH="$HOME/.local/bin:$PATH" +claude-agent-acp --version +claude-agent-acp --cli --version +claude-agent-acp --cli auth login --claudeai +claude-agent-acp --cli auth status --json +``` + +Use Node.js 22 or later and keep npm's optional dependencies enabled. +In fish, use `fish_add_path "$HOME/.local/bin"` instead of the `export` line. +The adapter bundles Claude Code; a separate installation is unnecessary. +The auth status must report a first-party Claude account with a Pro or Max plan, +not an API-key source. +Disable paid Usage credits in Claude's Settings > Usage if no paid overage is +permitted. +JP does not copy Claude Code's tokens. +Unnamed subscription entries select its active login; JP credential names are +not mapped to Claude Code accounts. + +The initial ACP implementation supports queries through JP's tool execution +service, including approvals, tool questions, result editing, and recording. +JP derives a separate Claude-native transcript from the current conversation for +each request; auxiliary queries do not share the main query's native session. + +```sh +jp query --new --auth sub --model anthropic/claude-opus-5 "Review this change." +``` + +This v0.1 path requires the runtime versions above. +The launcher uses Unix process groups or Windows job objects to clean up +descendant processes. +On Windows, npm's `claude-agent-acp.cmd` must be on PATH. +Model identifiers and aliases are passed to Claude Code without a JP allowlist. +Claude Code decides availability for the active account; JP reports +model-selection and request failures with the runtime's explanation. +Canonical identifiers returned for aliases are accepted and reported in usage +traces. +JP does not set or change `CLAUDE_CONFIG_DIR` for a default login: that variable +also selects credentials, including the macOS Keychain entry. +A user-supplied value is inherited unchanged. + +JP stores derived Claude Code conversations under +`~/.claude/projects/jp--/`, or under the +configured `CLAUDE_CONFIG_DIR`. +Auxiliary requests use a separate directory derived from their working +directory. +Session filenames remain unique per request; the real working directory passed +to Claude Code is unchanged. + +Restarting tool execution stops the attempt while keeping the original MCP call +open. +The MCP Host re-prepares the call before another execution attempt. +Claude Code chooses the output-token limit unless +`assistant.model.parameters.max_tokens` is explicitly set. +That override applies to each underlying model request, including its reasoning +tokens, not to the sum of every request in a Turn. + +Temperature, top-p, top-k, stop words, service tiers other than `off`, and +custom model parameters have no ACP mapping. +Non-default values are ignored with a warning in tracing output; they do not +prevent the query. +Compatibility failures do not switch to `direct` or to paid API access. +If Claude Code substitutes a `` file reference for a tool +result, JP reports the loss instead of accepting it silently. +There is no automatic file read-back or reconstruction for that case in v0.1; +request a smaller tool result. +The size hints preserve the measured large-result case, not arbitrarily large +results or batches. + +The reconstructed-history path has tests for provider switching, selected-turn +forks, replay, compaction, and changed instructions, schemas, and tool results. +Live qualification is opt-in; the [qualification procedure] describes the +production-provider cache comparison and how to interpret its usage reports. +Fixture tests alone do not establish live cache efficiency. + +### Prompt caching + +For ACP subscriptions, `assistant.request.cache = "off"` explicitly disables +caching. +Every other value leaves caching and retention to Claude Code; JP sends no TTL +override. +There is no `auto` value, and direct HTTP flows keep their own existing +cache-policy behavior. + +JP removes inherited cache environment overrides from the launched process. +Claude Code's own defaults and managed policy decide retention when caching is +not disabled. + +Debug tracing reports uncached input, cache writes, cache reads, and output +separately. +These diagnostics are not stored in conversation metadata. +Runtime aggregate totals are separate from main-request usage and must not be +added to it. +SDK dollar estimates are not subscription charges or quota percentages. +See the [qualification procedure] for the diagnostic format. + +### Direct subscription access + +Existing direct subscription users must opt in explicitly: + +```toml +[providers.llm.anthropic] +auth = ["subscription"] +subscription_flow = "direct" +``` + +This keeps JP-stored credentials and the existing direct HTTP behavior. +It carries Anthropic account-policy risk; explicit configuration does not make +it a vendor-sanctioned route. +Existing credentials are not deleted or imported into Claude Code. +Named subscriptions remain available with this flow. + [back to README] [back to README]: ../../README.md +[qualification procedure]: ../architecture/anthropic-acp-qualification.md diff --git a/docs/architecture/anthropic-acp-qualification.md b/docs/architecture/anthropic-acp-qualification.md new file mode 100644 index 000000000..188e3cbf4 --- /dev/null +++ b/docs/architecture/anthropic-acp-qualification.md @@ -0,0 +1,140 @@ +# Anthropic ACP qualification + +The ACP subscription flow accepts `claude-agent-acp` 0.76.0 with Claude Code +2.1.257. +Model availability is decided by the runtime, not a JP allowlist. +The cache qualification fixture uses `claude-opus-5`; that choice does not +restrict ordinary queries. +Qualification uses the production provider entry point, not a separate CLI +wrapper. +Automated protocol fixtures need no runtime, credentials, or quota. +They do not prove live cache hits or subscription allowance savings. + +## Prerequisites + +A live run needs `claude-agent-acp` on `PATH` and an active Pro or Max login: + +```sh +npm install --global --include=optional @agentclientprotocol/claude-agent-acp@0.76.0 +claude-agent-acp --cli auth login --claudeai +claude-agent-acp --cli auth status --json +``` + +Node.js 22 or later, with npm's optional dependencies enabled. +The status must report a first-party Claude account on a Pro or Max plan rather +than an API-key source. +Disable paid Usage credits in Claude's Settings > Usage if no overage is +permitted. + +## Usage accounting + +The ACP transport emits a debug tracing event with a JSON `usage` field. +It is diagnostic data, not conversation metadata. +Each value is a snapshot identified by `native_session_id`, not an increment. +The live test captures this tracing event with a test-only subscriber. + +- `requests` contains observed main-session model usage, keyed by native message + ID. + Repeated SDK observations update that entry. + Uncached `input_tokens`, `cache_creation_input_tokens`, + `cache_read_input_tokens`, and `output_tokens` remain separate. + Missing or null counters mean unreported, not zero. +- `cache_creation`, when supplied, separates five-minute and one-hour writes. +- `runtime.usage` and `runtime.model_usage` retain SDK aggregate snapshots. + These overlap the main requests and can include runtime helper activity. + Do not add them to the request counters or infer that every model in the + aggregate answered the user's request. +- `runtime.estimated_cost_usd` is the SDK's list-price estimate, not a bill or a + subscription quota measurement. + +Content is emitted and committed independently of usage reporting. +Replay and subagent messages do not enter the main-request accounting. +The transport logs its available snapshot when the connection finishes. +A cancelled or failed request may never receive final usage from the runtime. + +## Controlled live comparison + +`cache_reconstruction` replays `crates/jp_llm/tests/fixtures/acp/live.jsonl` by +default: no runtime is spawned and no allowance is spent, so it runs on every +commit like any other test. +A missing recording fails it rather than skipping it. + +`RECORD=1` reaches the installed runtime instead, adds the cache measurements +only a live service can answer, and writes the recording back. +That run spends subscription allowance, so confirm paid Usage credits are +disabled in Claude's Settings > Usage first — nothing in the test can check +that for you. +No API-key fallback is configured. + +From the repository root, with the pinned runtime on PATH: + +```sh +RECORD=1 cargo test -p jp_llm cache_reconstruction -- --nocapture +cargo insta accept +``` + +The test checks the adapter and runtime versions and the active login itself, +before it spends anything. +Capture them separately when a report needs them: + +```sh +claude-agent-acp --version +claude-agent-acp --cli --version +claude-agent-acp --cli auth status --json +``` + +Each run tags its system prompt with a fresh identifier, so an earlier run +cannot warm the initial prefix. +The test uses its own JP configuration, so workspace inquiry-model overrides do +not affect it. +It runs without tools and uses a synthetic invoice history with a long reference +prefix, a 128-token output limit, and a two-minute timeout per request. +It stops at the first failure. + +The cases are: + +| Case | What it checks | +| ----------------------------- | ---------------------------------------------------------------------- | +| Initial runtime-managed | Claude Code creates a cache entry using its own retention policy. | +| Reconstructed runtime-managed | An identical Thread in a different native session reads cached tokens. | +| `off` | The initial prefix produces neither cache reads nor writes. | + +Each successful request must also answer `INV-1042`. +Reports are printed before the cache assertions, so a failure retains the +counters that caused it. +TTL counters are retained when reported, but their values do not determine +whether the runtime-managed case passes. +Retain the reports and runtime versions; do not commit account-identifying auth +output. + +The repeated runtime-managed case exercises process restart and native-history +reconstruction through JP. +It proves useful reuse, **not equivalent efficiency to continuing an existing +Claude Code session**. +To measure that separately, capture the native state immediately before the +comparison prompt, run that prompt through normal native continuation, then run +the corresponding JP Thread through reconstruction. +Hold the model, working directory, tool definitions, instructions, and content +constant; account for runtime-added context and run within the cache TTL. +JP does not enforce `short`, `long`, or custom retention durations in this flow. +Only `off` changes the runtime's caching policy. + +The existing protocol and transcript fixtures cover replay exclusion, tool ID +stability, repeated SDK message IDs, runtime helper totals, configuration +mapping, and unchanged model-visible history across native bookkeeping changes. +The live test does not qualify arbitrary tool inventories or tool-result sizes. + +## Subscription observations + +Record plan usage immediately before and after a controlled run, with other +account activity paused and no quota reset crossing the run. +Record the active model and cache policy alongside those observations. +The plan's usage display can be delayed or coarse; report that uncertainty +rather than derive a precise multiplier from a small change. + +More cache hits for the same work generally conserve input-processing expense, +but cache hits still occupy the context window. +API pricing ratios and SDK dollar estimates are not a published formula for +subscription-window percentages. +Neither latency benchmarks nor credential-switch experiments are required by +this procedure. diff --git a/docs/architecture/ubiquitous-language.md b/docs/architecture/ubiquitous-language.md index 56c077ad4..5d9be66cc 100644 --- a/docs/architecture/ubiquitous-language.md +++ b/docs/architecture/ubiquitous-language.md @@ -32,7 +32,10 @@ In disagreements between code and docs, the code is authoritative. - [Event Overlay](#event-overlay) - [InlineReply](#inlinereply) - [Inquiry](#inquiry) + - [Invocation](#invocation) + - [JP MCP Server](#jp-mcp-server) - [Match](#match) + - [MCP Host](#mcp-host) - [Persona](#persona) - [Pinned Conversation](#pinned-conversation) - [Provider](#provider) @@ -40,6 +43,7 @@ In disagreements between code and docs, the code is authoritative. - [Search Hit](#search-hit) - [Service Tier](#service-tier) - [Signal Router](#signal-router) + - [Subscription Flow](#subscription-flow) - [Summary](#summary) - [Thread](#thread) - [Tool Call](#tool-call) @@ -55,8 +59,8 @@ In disagreements between code and docs, the code is authoritative. ### Attachment External content attached to a conversation to provide context: a file, URL -contents, command output, Bear note, MCP resource, etc. Implemented as -`Attachment` in `jp_attachment`. +contents, command output, Bear note, etc. Implemented as `Attachment` in +`jp_attachment`. Each attachment kind is a separate crate (`jp_attachment_file_content`, `jp_attachment_cmd_output`, and so on). @@ -219,6 +223,33 @@ Carried as `InquiryRequest` and `InquiryResponse` events within a conversation. Used for mid-turn clarification that should not appear in the main chat stream or be sent to the LLM provider as context. +### Invocation + +One execution of one tool call inside the [JP MCP Server](#jp-mcp-server), from +the moment the call is admitted to the moment its result is recorded. +It carries an identity the server assigns itself, so two callers asking for the +same tool with the same arguments at the same time remain distinguishable. +Implemented as `InvocationId` in `jp_mcp::server::service`. + +**Not the same as** a [Tool Call](#tool-call), which is the pair of conversation +events an invocation produces, nor a transport request ID, which belongs to +whichever protocol carried the call. + +A tool that asks for input ends its execution attempt and runs again with the +answer; both attempts belong to the same invocation. + +### JP MCP Server + +The in-process service that executes tool calls: it resolves the tool, validates +arguments, runs the local command, built-in, or upstream MCP tool, and produces +the result. +It owns no conversation and makes no policy decision about who answers a +question; it asks the [MCP Host](#mcp-host) for each decision it needs. +Implemented as `Service` in `jp_mcp::server::service`. + +**Not the same as** a third-party MCP server, which is an external process +configured under `providers.mcp` and reached *through* this one. + ### Match A [Search Hit](#search-hit) whose line actually contains the pattern, as opposed @@ -231,6 +262,20 @@ heading, the `--output count` value, and the `--max-matches` cap. **Not the same as.** A Search Hit, which also covers context lines. +### MCP Host + +The side of a tool call that owns everything the [JP MCP Server](#jp-mcp-server) +deliberately does not: admission, argument and result editing, inquiry routing, +and writing the conversation. +The server asks; the Host decides and records. +In JP the Host is the CLI process, reached through the private channel a +`HostReceiver` carries. + +**Not the same as** an MCP client, which is any caller that invokes tools over +the protocol. +The Host is one such caller, and a third-party client is another; only the Host +answers the server's decisions. + ### Pinned Conversation A conversation the user has marked as important, so it stays prominent and is @@ -313,6 +358,16 @@ interrupt down the stack. The registered scopes are the streaming loop, the tool execution loop, and the turn-level handler covering gaps between turn phases. +### Subscription Flow + +The request implementation selected for subscription authentication within a +provider. +Represented by `SubscriptionFlow` in `jp_config::providers::llm::anthropic`. + +**Not the same as** a credential, model, or service tier: selecting a flow does +not select an account, change the model ID, or authorize another billing kind. +API-key requests do not use this selection. + ### Summary Text that stands in for a range of turns in the [Compacted diff --git a/docs/rfd/.priority.json b/docs/rfd/.priority.json index 61fe4a6cf..d69c25897 100644 --- a/docs/rfd/.priority.json +++ b/docs/rfd/.priority.json @@ -96,7 +96,7 @@ "D27", "D28", "D30", - "D31", + "108", "D35", "D36", "D39", diff --git a/docs/rfd/014-attachment-handler-guide.md b/docs/rfd/014-attachment-handler-guide.md index 1f27759b3..281261e76 100644 --- a/docs/rfd/014-attachment-handler-guide.md +++ b/docs/rfd/014-attachment-handler-guide.md @@ -126,7 +126,7 @@ pub trait Handler: Debug + DynClone + DynHash + Send + Sync { - **`list()`** — returns all stored attachment URLs. Used by `jp attachment ls`. Should produce canonical (hierarchical) URLs for consistency. -- **`get(cwd, mcp)`** — fetches and returns the actual attachment content. +- **`get(cwd)`** — fetches and returns the actual attachment content. This is where the handler does its real work: reading files, running commands, making HTTP requests, etc. diff --git a/docs/rfd/090-anthropic-subscription-auth-with-credential-fallback.md b/docs/rfd/090-anthropic-subscription-auth-with-credential-fallback.md index 319c31857..14872f783 100644 --- a/docs/rfd/090-anthropic-subscription-auth-with-credential-fallback.md +++ b/docs/rfd/090-anthropic-subscription-auth-with-credential-fallback.md @@ -5,6 +5,7 @@ - **Authors**: Jean Mertz - **Date**: 2026-07-03 - **Tracking Issue**: [\#875] +- **Extended by**: [RFD 110] - **Summary**: OAuth subscription auth for Anthropic with automatic fallback chain, credential store, and scoped cooldown tracking across profiles. @@ -909,6 +910,7 @@ Depends on Phases 1 and 2c; independent of Phases 2 and 3. - [Using Claude Code with your Pro or Max plan][claude-plans] [RFD 048]: 048-four-channel-output-model.md +[RFD 110]: 110-anthropic-subscription-queries-via-acp.md [\#875]: https://github.com/dcdpr/jp/issues/875 [claude-code-source]: https://github.com/alex000kim/claude-code [claude-plans]: https://support.claude.com/en/articles/11145838-using-claude-code-with-your-pro-or-max-plan diff --git a/docs/rfd/drafts/D31-transitional-jp-protocol-bridge-for-mcp-tools.md b/docs/rfd/108-transitional-jp-protocol-bridge-for-mcp-tools.md similarity index 93% rename from docs/rfd/drafts/D31-transitional-jp-protocol-bridge-for-mcp-tools.md rename to docs/rfd/108-transitional-jp-protocol-bridge-for-mcp-tools.md index f65dd58b5..b7415646c 100644 --- a/docs/rfd/drafts/D31-transitional-jp-protocol-bridge-for-mcp-tools.md +++ b/docs/rfd/108-transitional-jp-protocol-bridge-for-mcp-tools.md @@ -1,9 +1,12 @@ -# RFD D31: Transitional JP Protocol Bridge for MCP Tools +# RFD 108: Transitional JP Protocol Bridge for MCP Tools -- **Status**: Draft +- **Status**: Discussion - **Category**: Design - **Authors**: Jean Mertz - **Date**: 2026-05-15 +- **Summary**: A stopgap bridge that lets MCP tools return `jp_tool::Outcome` + and receive JP's tool execution context through `_meta`, until typed content + blocks land. ## Summary @@ -301,9 +304,8 @@ pattern. - **Stateful tool protocol status.** Out of scope; see [RFD 009] and the `computer.jp/status` field defined in [RFD 058]. - **Restructuring the three-way dispatch in `ToolDefinition::execute`.** Out of - scope; see [RFD D10]. - This RFD modifies `execute_mcp` in place; if [RFD D10] lands first the same - logic moves into `McpRuntime::execute`. + scope. + This RFD modifies `execute_mcp` in place. - **Promoting the transitional protocol to a permanent JP feature.** This RFD is explicitly transitional. If [RFD 058] is later rejected and the project decides to keep `Outcome` as @@ -341,21 +343,6 @@ disclaimer) are the primary mitigation, but they're not enforceable. Whether this is a problem depends on how aggressively external authors adopt the protocol before [RFD 058] is ready. -### Interaction with [RFD D10] - -[RFD D10] proposes extracting the three execute paths into a `ToolRuntime` -trait. -This RFD modifies `execute_mcp` directly. -Sequencing options: - -- This RFD lands first; [RFD D10] moves the logic into `McpRuntime::execute`. -- [RFD D10] lands first; this RFD adds the logic to the new - `McpRuntime::execute`. -- Both land in parallel; whoever merges second pays a small merge cost. - -None of these is harmful; they just need coordination in the implementation plan -if both are active. - ## Implementation Plan ### Phase 1: shared tool context builder @@ -442,17 +429,14 @@ This is the dogfooding check that proves the protocol is wired correctly. on [RFD 058]; out of scope here. - [RFD 009]: Stateful Tool Protocol (Accepted) — the stateful tool lifecycle is layered above the single-execution model this RFD touches. -- [RFD D10]: Unified Tool Execution Model (Draft) — structural refactor at the - dispatch layer; coordination noted in Risks. - [SEP-1319]: MCP request-params `_meta` field — the protocol surface this RFD attaches metadata to. -[RFD 009]: ../009-stateful-tool-protocol.md -[RFD 028]: ../028-structured-inquiry-system-for-tool-questions.md -[RFD 042]: ../042-tool-options.md -[RFD 058]: ../058-typed-content-blocks-for-tool-responses.md -[RFD 065]: ../065-typed-resource-model-for-attachments.md -[RFD 066]: ../066-content-addressable-blob-store.md -[RFD 067]: ../067-resource-deduplication-for-token-efficiency.md -[RFD D10]: D10-unified-tool-execution-model.md +[RFD 009]: 009-stateful-tool-protocol.md +[RFD 028]: 028-structured-inquiry-system-for-tool-questions.md +[RFD 042]: 042-tool-options.md +[RFD 058]: 058-typed-content-blocks-for-tool-responses.md +[RFD 065]: 065-typed-resource-model-for-attachments.md +[RFD 066]: 066-content-addressable-blob-store.md +[RFD 067]: 067-resource-deduplication-for-token-efficiency.md [SEP-1319]: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1319 diff --git a/docs/rfd/109-in-process-jp-mcp-server.md b/docs/rfd/109-in-process-jp-mcp-server.md new file mode 100644 index 000000000..18d95b61f --- /dev/null +++ b/docs/rfd/109-in-process-jp-mcp-server.md @@ -0,0 +1,475 @@ +# RFD 109: In-Process JP MCP Server + +- **Status**: Implemented +- **Category**: Design +- **Authors**: Jean Mertz +- **Date**: 2026-09-12 +- **Summary**: Tool execution moves into an in-process JP MCP Server that JP and + third-party MCP clients both call over loopback Streamable HTTP. + +## Summary + +JP moves per-call tool execution into `jp_mcp::server`, running inside the JP +CLI process. +JP and third-party MCP clients use the same MCP invocation path over loopback +Streamable HTTP; a private in-process channel connects the JP MCP Server to JP +for interactions and lifecycle control. +The coordinator, inquiry routing, and conversation storage remain outside the JP +MCP Server. + +## Motivation + +Tool execution is split between `jp_llm::tool` and `jp_cli::cmd::query::tool`. +Exposing it to Claude Code must not create another implementation of approvals, +questions, argument editing, or result delivery. +A wrapper that delegates the execution pipeline back into the CLI leaves that +ownership problem in place. + +This RFD extracts the execution service, not the entire coordinator or agent +loop from [RFD 026]. +It provides the MCP dependency needed by future RFDs without waiting for the +full typed-content and attachment migrations in [RFD 058] and [RFD 065]. + +## Design + +### User-facing behavior + +Users keep their existing `conversation.tools` and `providers.mcp` +configuration. +Local commands, built-in tools, and tools supplied by configured MCP servers +remain available through ordinary queries: + +```sh +jp query --new "Run the configured checks." +``` + +JP starts its execution service and HTTP endpoint automatically. +Users do not start a daemon, select a port, copy tool definitions, or configure +another MCP server to run a normal query. +The service adds no external runtime dependency; Claude Code installation and +subscription setup belong to a separate RFD. + +The initial deployment is exclusively in-process. +A future `jp mcp serve` command for long-running service deployment is outside +this RFD. +Third-party MCP servers retain their existing stdio configuration and +child-process lifecycle; no HTTP variant is added to `providers.mcp`. + +### Roles and terminology + +| Term | Meaning in this design | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| **MCP Host** | JP's CLI process. It boots the JP MCP Server, supplies trusted configuration and context, and handles interaction and recording requests. | +| **JP MCP Server** | `jp_mcp::server`, the in-process service responsible for per-call tool execution. | +| **JP MCP Client** | The upstream MCP client component owned by the JP MCP Server. It connects to and invokes third-party MCP servers. | +| **Third-party MCP Servers** | The MCP servers configured under `providers.mcp`, started and managed by the JP MCP Server through the JP MCP Client. | +| **Third-party MCP Client** | An external caller, initially Claude Code, requesting tools from the JP MCP Server. | + +The MCP Host also makes ordinary MCP requests when JP drives the model/tool loop +itself. +That Host-side connection is distinct from the upstream JP MCP Client. + +### One invocation path + +```text +MCP Host -- HTTP --> JP MCP Server <-- HTTP -- Third-party MCP Client + | + +------------+-------------+ + | | | + local command built-in JP MCP Client + | + stdio + | + third-party MCP server + +MCP Host <-------- private typed channels --------> JP MCP Server +``` + +Both HTTP callers use the same MCP handlers, catalog, validation, preparation, +execution, and result processing. +There is no Host-only invocation shortcut into an executor. +An in-memory transport can be added later if justified, but it must carry the +same MCP messages through those handlers, not introduce a second execution API. + +The private channel serves a different purpose: it supplies Host services while +an MCP call is being processed. +Third-party MCP clients cannot send Host commands over HTTP. +They can perform ordinary MCP initialization, discovery, calls, and cancellation +of their own requests. + +### Ownership and crate boundaries + +The JP MCP Server owns tool resolution, per-call requirements and state, +argument/answer validation, process invocation, accumulated answers, and the +final MCP response. +It executes configured custom argument formatters through the same controlled +command-launching path when presentation requests them; pure terminal formatting +stays with the MCP Host. + +The MCP Host owns query phases, the execution plan, terminal/editor interaction, +turn-scoped remembered decisions, inquiry routing, and conversation writes. +`ToolCoordinator` remains in `jp_cli` and delegates per-call execution. +Moving that coordinator belongs to [RFD 026], not this extraction. + +Extract the execution portions of `ToolDefinition::execute`, local command +handling, upstream dispatch, and the existing `BuiltinTool`/`BuiltinExecutors` +registry. +Tool descriptions and the minimum shared result/input contracts live in +`jp_tool`; configuration-dependent construction and execution do not move into +that lightweight SDK. +Provider-specific schema adaptation stays in `jp_llm`. + +The JP MCP Server does not depend on `jp_llm`, `jp_workspace`, or +`jp_conversation`. +Its inputs are resolved configuration and owned context data, not a Workspace, a +provider, or a ConversationStream. +Conversation event wrapping and inquiry provenance conversion belong at the MCP +Host boundary; genuinely shared payload information belongs below both +consumers. + +Introduce `client` and `server` features on `jp_mcp`. +The `server` feature enables the client machinery needed for upstream stdio +connections. +The MCP Host's HTTP connection uses the MCP transport library without +generalizing the upstream configuration surface. +Feature selection limits client-only dependencies; it is not a way to conceal +dependency cycles. + +Remove the MCP-client parameter from the base attachment-handler interface and +its callers when retiring `jp_attachment_mcp_resources` resolution. +Plugin-based MCP attachments can be designed separately. +Existing stored handler data must remain loadable, with a clear +unsupported-resolution error when used. +This small cleanup removes `jp_attachment`'s MCP dependency without implementing +an attachment redesign; it does not remove MCP tools or their resource results. + +### Host-only interaction + +The MCP Host supplies `conversation.tools`, `providers.mcp`, the working root, +invocation identity, and existing access-approval data for the active context. +The JP MCP Server does not discover or choose another workspace. +Bind each server instance to its supplied context; concurrent queries must not +share mutable configuration, answers, or pending interactions by accident. + +The private channel carries correlated requests and replies for: + +- Admission, approval, and argument editing. +- Tool input requests, including supporting content and sensitivity constraints. +- Result review, editing, and delivery decisions. +- Conversation recording acknowledgements. +- Execution release, cancellation, and shutdown. + +The JP MCP Server determines which per-call interaction is required and +validates its reply. +The MCP Host decides how to obtain that reply. +In particular, the JP MCP Server makes no distinction between user-targeted and +assistant-targeted inquiries. +The MCP Host applies configured answers, remembered answers, question targets, +and assistant overrides, presents prompts or calls an assistant, and records the +exchange. +Secret answers retain their existing routing and redaction rules and do not +enter ordinary progress events or logs. + +Remembering a decision for a Turn remains a Host responsibility. +The JP MCP Server can request an interaction for each invocation and receive an +automatic Host reply; it does not interpret the lifetime of an MCP connection as +a JP Turn. + +The JP MCP Server having no terminal does not authorize unattended execution. +The MCP Host applies existing non-interactive policy; changing that policy is +outside this RFD. + +The private interface is created by the MCP Host when it starts the JP MCP +Server. +Client names, MCP session IDs, and request metadata do not grant access to it. +Nor can a tool request override configuration, access grants, or accumulated +answers by supplying its own context metadata. + +### Preparation and release + +A call being prepared is not yet authorized to execute. +The common call path resolves the tool, validates its arguments, obtains +required Host decisions, and waits for release. +Edited arguments are validated again; configured formatter ordering and +visibility remain part of the interaction contract. + +For JP-driven model loops, the MCP Host can start preparation as a tool call +request finishes streaming while retaining the existing execution-phase barrier. +It releases calls according to the execution plan derived from the conversation, +not an independent list of queued HTTP requests. +For an external agent, the MCP Host can release an admitted call as soon as the +required preparation finishes. +The JP MCP Server uses the same path in both cases; the MCP Host controls +release timing. + +The MCP Host must keep servicing its private channel and model stream while MCP +requests are outstanding. +Awaiting a final HTTP result in the only task capable of releasing the call or +answering its inquiry would deadlock. + +### Inquiries re-run tools + +`Outcome::NeedsInput` ends an execution attempt. +It does not suspend a tool process for later resumption: + +```text +MCP tools/call + -> JP MCP Server executes tool + -> attempt finishes with NeedsInput + -> JP MCP Server requests an answer from MCP Host + -> MCP Host obtains and returns the answer + -> JP MCP Server executes tool again with accumulated answers + -> attempt finishes with the result + -> MCP Host handles result delivery and recording + -> final MCP response +``` + +Further questions repeat that sequence. +The enclosing MCP call can remain outstanding throughout; each tool execution +attempt has finished before its answer is obtained. +A persistent third-party MCP server can stay alive between attempts, but its +JP-aware tool is invoked again with the answers. +Built-ins are called again as well. + +The tool author remains responsible for making this re-execution safe, as under +the existing protocol. +This RFD does not add suspended tool invocations, stateful task handles, or a +new upstream MCP elicitation implementation. + +### Narrow content-model integration + +Use [RFD 058]'s ordered content and input-request representation for the service +boundary. +Introduce the minimum shared `ContentBlock` and `InputRequest` data and +conversions needed for text, resource data, questions, and structured error +information. +Retain other native MCP content variants and metadata for forwarding without +requiring the MCP Host to render them or persist them as typed blocks. +Carry the MCP-standard resource fields as data; do not require [RFD 065]'s +attachment placement, refresh, or canonicalization work to use them. + +This RFD does **not** require completion of RFD 058 or RFD 065. +The implemented shared definitions are the ones those migrations consume, not a +competing service-specific content model. + +| Included here | Deferred | +| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| Shared ordered result content and schema-described input requests | Typed conversation-file migration and conversion of every provider/renderer | +| Conversions from existing `Outcome` and MCP results | Mandatory migration of local tools to a new stdout protocol | +| Retention of native MCP content and metadata at the JP MCP Server boundary | New binary tool-result rendering in JP's existing provider flows | +| Existing text/error projection for the MCP Host | Resource deduplication, blob storage, attachment refresh, and stateful tool lifecycle | + +Existing local and built-in tools keep working without changes. +New shared input requests retain the secrecy constraints of existing questions. +Malformed recognized envelopes are failures, not permission to silently discard +content. +The JP MCP Server must not lose mixed native MCP content while looking for a JP +result inside it. + +The MCP Host adapts results to the existing `ToolCallResponse` representation +and rendering where needed. +Ordinary text/error results retain their existing serialized shape. +This compatibility projection is explicit and shared across invocation paths; +full typed persistence remains RFD 058 work. +A result edited by the MCP Host replaces the delivered content, rather than +allowing the JP MCP Server to return an earlier unedited value. + +JP question blocks are not standard MCP result blocks. +Resolve them through the private Host interface before returning the final +standard MCP result. +Do not assume Claude Code interprets a raw `NeedsInput` envelope or a JP +question block as a request to the JP user. + +Retaining `Outcome` as an input decoder is a deliberate difference from RFD +058's coordinated removal of that decoder. +It avoids making tool migration a prerequisite for this service; a later removal +requires its own compatibility decision. + +### JP-aware upstream MCP tools + +Adopt the narrowly scoped interoperability from [RFD 108], within the new +execution path rather than as another implementation in `jp_llm`. + +For a result containing exactly one MCP text block, attempt to parse the entire +text as `jp_tool::Outcome`. +A successful parse uses Outcome semantics and is converted to the shared +representation. +No opt-in or protocol advertisement is required. +Do not concatenate mixed content to make it parseable or recursively unwrap +strings within a successful result. +An MCP error flag conflicting with `Outcome::Success` wins, as specified by 108. + +A literal JSON document can therefore be interpreted as an Outcome when its +shape matches. +This collision risk is explicitly accepted. +`Outcome` remains a tool-result envelope; it does not authorize execution or +change Host policy. +Otherwise, preserve the native MCP result. +Local stdout retains the existing Outcome and raw-text paths, with typed content +decoding added at the boundary. +A transient-error hint does not authorize blind replay after a lost connection. + +A shared context builder supplies local command templates and, for upstream MCP +calls, `_meta["computer.jp/tool"]` and `_meta["computer.jp/context"]`. +It includes the appropriate invoked name, validated execution arguments, +accumulated answers, options, and trusted invocation context. +Any duplicated arguments derive from the same post-edit value. +Build this metadata from Host configuration and replies; never trust incoming +metadata as those values. + +This reuses 108's request plumbing and Outcome compatibility without requiring +its separate implementation or broadening the first delivery into new MCP +features. + +### Recording, correlation, and lifetime + +The JP MCP Server assigns invocation identity independently of transport request +IDs and carries caller correlation metadata to the MCP Host. +The MCP Host associates it with the appropriate tool call and ensures each event +is recorded once. +Host communication distinguishes requested arguments from edited execution +arguments and raw results from approved or edited delivery content. +Neither matching arguments nor arrival order identifies a call: simultaneous +identical invocations are valid. +Interpreting Claude Code's `claudecode/toolUseId` metadata belongs to a future +RFD integration, not the execution policy. +Host-supplied tool-description metadata can likewise carry its result-size hints +without introducing an Anthropic dependency into the JP MCP Server. + +Execution release and final delivery respect Host recording acknowledgements. +The MCP Host applies its configured persistence policy; a non-persisting +invocation is not forced to write a conversation to disk. +The JP MCP Server owns no conversation lock. +Historical event replay does not submit new calls, and observed ACP tool events +must not enqueue a second execution of an MCP call. + +Use the existing cancellation pattern: the MCP Host sends a scoped stop command +or cancellation token and waits for cleanup. +Stopping current calls and shutting down the JP MCP Server are distinct +operations. +Shutdown stops admission, cancels pending interactions and calls, and closes +owned upstream clients. +The JP MCP Server's HTTP listener dies with the JP process; child-process +cleanup still follows JP's existing execution mechanisms. + +The JP MCP Server continues handling control messages while individual calls +wait on answers. +Bound progress buffering separately from required interactions so a slow display +does not block draining a tool's stderr. +A transient HTTP disconnection is not itself cancellation or authority to +execute again. +A crash after a side effect but before recording leaves an uncertain outcome, +not an exactly-once guarantee. + +### HTTP and initial security scope + +Use MCP [Streamable HTTP], not the legacy HTTP+SSE transport. +Bind to loopback on an OS-assigned port and supply the endpoint to callers +programmatically. +JP's stdin/stdout retain their CLI purpose. +The separate ACP connection used by a future RFD is outside the MCP transport. + +The initial endpoint has no authentication token or login flow. +This is an explicit local-access trade-off: loopback does not establish caller +identity, and another local process can submit requests under the bound tool +policies. +Validate Host and supplied Origin headers using the controls provided by +`rmcp`'s `StreamableHttpServerConfig`, with tests for rejected requests. + +Sandboxing stays at the current level. +Preserve access-policy compilation and cooperative enforcement; moving code into +a server does not create an OS sandbox. +Future confinement work can use these execution boundaries, but is not part of +this delivery. + +## Drawbacks + +Using HTTP for JP's own calls adds serialization and lifecycle work. +It buys one invocation path and avoids a second private execution API. +There is no latency benchmark gate; investigate an in-memory MCP transport only +if it solves a measured problem. + +The local endpoint accepts unauthenticated callers, and speculative Outcome +recognition can reinterpret text. +Both are explicit initial trade-offs, not claims of stronger isolation. +The compatibility result projection also does not deliver RFD 058's complete +typed-persistence benefits. + +## Alternatives + +**execution-host wrapper.** Exposes tools while leaving more execution ownership +in the existing CLI arrangement. +This RFD replaces that execution machinery and makes the MCP Host a caller of +the same service as external clients. + +**A separate runtime crate.** Unnecessary for the narrowed execution service. +`jp_mcp::server` and feature separation are sufficient without importing the +coordinator, LLM inference, or workspace storage. + +**A direct Host execution API plus MCP for external clients.** Creates another +invocation path. +Rejected; transport may vary later, execution semantics may not. + +**Require all of RFD 058 first.** Expands the prerequisite into storage, +provider, renderer, and attachment migrations. +The shared types and legacy conversion supply the required interface without +delaying a future RFD for that work. + +## Non-Goals + +- Extracting `ToolCoordinator` or implementing RFD 026. +- Implementing a future RFD's ACP provider flow, native transcript conversion, + or subscription authentication. +- Separate-process deployment, controller IPC, a long-running daemon, or `jp mcp + serve`. +- HTTP transport for configured third-party MCP servers. +- OS sandboxing, a new built-in plugin framework, suspended tool execution, MCP + tasks, sampling, or new upstream elicitation support. +- Completing RFD 058/065 or changing existing tool and conversation formats as a + prerequisite. + +## Implementation Plan + +1. **Shared contracts and dependency cleanup.** Introduce the minimum shared + result/input types and compatibility conversions. + Move tool descriptions and tool-domain errors out of their accidental LLM + ownership. + Remove the attachment-handler MCP coupling without a plugin redesign. + Keep existing execution working during these mechanical changes. +2. **Execution service and Host interaction.** Implement `jp_mcp::server` behind + feature flags. + Reuse command execution and the built-in registry; add the private Host + channel, preparation/release, Outcome re-execution, and scoped cancellation. + Tests use real executor creation and controlled tool fixtures. +3. **One HTTP path and CLI adoption.** Add the Streamable HTTP endpoint and make + JP's ordinary query path its MCP caller. + Keep the coordinator and event ownership in JP. + Add 108 metadata/Outcome handling to upstream stdio calls; do not maintain a + parallel production execution pipeline. +4. **future RFD readiness.** Exercise a third-party MCP client against the same + service while the MCP Host handles interactions. + Verify correlation metadata, result-size metadata, edited results, and + recording before final response. + No Anthropic credential or transcript implementation is needed to test this + contract; future RFD consumes the completed service afterward. + +Acceptance tests cover both MCP callers through the same handlers. +Force denied calls, malformed arguments, stale/duplicate interaction replies, +and cancelled prompts, and prove forbidden execution did not occur. +An inquiry fixture must prove separate executions with the accumulated answer, +one logical final result, and no claim of process resumption. +Pin exact CLI output, request/result pairing, and stored text/error results. +Exercise simultaneous identical calls, Host loss, shutdown, failed recording, +and HTTP disconnect without duplicate side effects. +Test client-only and server feature builds. + +This RFD can be implemented and used by future RFD without completing the +broader RFD 058, RFD 065, or RFD 026 migrations. +Their documents remain separate references; this delivery establishes only the +shared contracts and execution behavior specified here. + +[RFD 026]: 026-agent-loop-extraction.md +[RFD 058]: 058-typed-content-blocks-for-tool-responses.md +[RFD 065]: 065-typed-resource-model-for-attachments.md +[RFD 108]: 108-transitional-jp-protocol-bridge-for-mcp-tools.md +[Streamable HTTP]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http diff --git a/docs/rfd/110-anthropic-subscription-queries-via-acp.md b/docs/rfd/110-anthropic-subscription-queries-via-acp.md new file mode 100644 index 000000000..28a14aca7 --- /dev/null +++ b/docs/rfd/110-anthropic-subscription-queries-via-acp.md @@ -0,0 +1,508 @@ +# RFD 110: Anthropic Subscription Queries via ACP + +- **Status**: Accepted +- **Category**: Design +- **Authors**: Jean Mertz +- **Date**: 2026-09-11 +- **Extends**: [RFD 090] +- **Summary**: The `anthropic` provider runs subscription queries through + `claude-agent-acp` and the official Claude Code runtime by default, keeping + the direct flow as an explicit option. + +## Summary + +The `anthropic` provider gains an ACP subscription flow through +`claude-agent-acp` and the unmodified Claude Code runtime. +It becomes the default for subscription authentication; the existing direct flow +remains available by explicit configuration. +Model IDs, the `--auth` interface, JP conversation ownership, and JP tool +policies remain unchanged. + +## Motivation + +[RFD 090] and [PR 1151] through [PR 1153] provide subscription credentials, +provider-owned credential selection, fallback, and the `--auth` flag. +The direct Anthropic subscription implementation sends requests from JP using +JP-stored Claude Code OAuth credentials. +It works, but is not a vendor-sanctioned third-party authentication path and +risks account restriction. + +Anthropic's June 15, 2026 [subscription clarification] states that Agent SDK, +`claude -p`, and third-party application usage continue to draw from +subscription allowances. +Running the official runtime avoids extracting its credentials or reimplementing +its authentication. +This is an additional subscription flow, not another provider or another route +to mandatory per-token billing. + +The probe harness in `.config/jp/experiments/` demonstrates role-bearing history +reconstruction, tool execution through MCP, and the controls needed for an +integration. +A separate public ACP provider would expose an implementation choice the user +need not make. +JP's MCP server, specified in [RFD 109], exposes JP's configured tool pipeline +to external clients; this flow consumes its hosted form. + +## Design + +### User interface and setup + +The following commands select billing without changing the provider or model: + +```sh +jp q -n --auth sub -m anthropic/claude-opus-5 "Review this change." +jp q -n --auth api -m anthropic/claude-opus-5 "Review this change." +``` + +For ACP subscription usage, install Node.js 22 or later and npm, then install +and authenticate the adapter release used in the experiments: + +```sh +npm install --global @agentclientprotocol/claude-agent-acp@0.76.0 +claude-agent-acp --cli auth login --claudeai +claude-agent-acp --cli --version +claude-agent-acp --cli auth status --json +``` + +Keep npm's optional dependencies enabled. +On supported platforms the SDK supplies Claude Code's native binary; a separate +Claude Code installation is not normally necessary. +`node` and `claude-agent-acp` must be on JP's `PATH`. +The measured baseline is adapter 0.76.0 with Claude Code 2.1.257, not an +unqualified promise about every later release. + +Login uses Claude Code's own browser flow and credential storage. +No token is copied into JP. +Disable paid **Usage credits** in Claude's **Settings > Usage** when only the +included subscription allowance may be used. +JP verifies effective subscription authentication before inference; an API key, +helper, or cloud configuration must not silently select another billing source. +Child-process configuration must not alter the parent environment or JP's API +flow. + +With the default ACP subscription flow, no TOML changes are required when the +command explicitly selects `--auth sub` and the model. +To make these the workspace defaults, merge this into `.jp/config.toml`: + +```toml +[providers.llm.anthropic] +auth = ["subscription"] +subscription_flow = "acp" + +[assistant.model] +id = "anthropic/claude-opus-5" +``` + +`subscription_flow` defaults to `acp`, so its line is optional. +After setup, ordinary queries suffice: + +```sh +jp query --new "Review the changes in this workspace." +jp query "Focus on error handling." +``` + +JP starts the adapter and its hosted MCP server, prepares the conversation, +handles tool interaction, and records the response. +The user runs no daemon, copies no tool configuration into Claude Code, and +manages no external session IDs. +Existing assistant settings, instructions, attachments, and tools stay in JP +configuration. + +### Flow selection and migration + +A *subscription flow* selects the implementation used for a subscription +authentication entry. +It does not select the billing kind, model, or service tier. +`subscription_flow` accepts `acp` and `direct`, rejects unknown values, and +follows ordinary scalar config layering and conversation deltas. + +| Authentication | `subscription_flow` | Implementation | +| ---------------------- | ------------------- | --------------------------------------------------------------------- | +| `api_key` / `api` | Either value | Existing JP Anthropic API implementation. | +| `subscription` / `sub` | `acp` (default) | Claude Code through the qualified ACP adapter. | +| `subscription` / `sub` | `direct` | Existing JP-stored subscription credentials and direct HTTP requests. | + +To retain the direct subscription flow: + +```toml +[providers.llm.anthropic] +subscription_flow = "direct" +``` + +This is explicit acceptance of that flow's policy and account risk, not a claim +that opting in makes it permitted. +JP never selects it automatically because ACP is missing, unsupported, or fails. +API-only users keep their behavior and need no Node or Claude Code installation. +The existing default auth chain stays `["api_key"]`; adding this field does not +move API users onto subscriptions. + +Subscription users who omit `subscription_flow` must install and authenticate +the external runtime or explicitly select `direct`. +Existing JP credentials are neither deleted nor imported into Claude Code. +Initial ACP support uses the runtime's active subscription login. +An unmapped named JP subscription credential must fail rather than silently use +that account; existing named credentials remain usable with `direct`. +Native-login name mapping and integration with `jp provider llm auth` are +follow-up work. + +The existing credential-chain and `--auth` semantics remain authoritative. +Listing an API entry explicitly authorizes the existing paid fallback policy; +this feature adds no API entry and no automatic switch between subscription +flows. +Subscription-only requests, including auxiliary requests using that +configuration, stop when their allowance is unavailable. +Paid usage credits in Claude's account are separate from JP's auth chain; the +setup requirement above is not replaceable by a cache-cost estimate. + +### Provider and execution boundaries + +```text +anthropic provider + +-- API-key authentication --> existing API implementation + +-- subscription/direct ----> existing direct subscription implementation + +-- subscription/acp -------> internal Claude integration + +-- ACP client and runtime lifecycle + +-- Thread-to-native-transcript conversion + +-- streamed events and notices + +-- JP MCP execution host +``` + +There is no public `acp` provider, agent selector, or change to `model.id`. +Internal ACP transport can be reusable, but this feature supports the qualified +Claude adapter, not arbitrary ACP executables. +The implementation owns the adapter command and version compatibility policy; it +does not expose an unrestricted SDK-options bag as a substitute for JP +configuration. + +Credential policy stays in the provider as in [PR 1151]. +Protocol and transcript conversion belong with the Claude integration, not in +command handlers. +[RFD 109] owns tool execution and the execution-host interface. +The query runner must service that host while the ACP prompt is in flight: +waiting for prompt completion before servicing MCP calls deadlocks. +Reuse the existing tool coordination and rendering rather than duplicate them in +the provider. + +An ACP prompt covers the external agent's model/tool continuation loop. +Internal orchestration must represent that explicitly rather than send its +observed tool calls through JP's API-style execution phase a second time. +How the internal provider/request interface carries this execution contract is +settled in the first vertical slice; the public provider and authentication +choice do not expose it. + +### JP remains the conversation authority + +The flow consumes the same Thread and provider-visible projection as the +Anthropic request builder, including the Compacted View. +It separates the pending input from prior history, constructs Claude-native +records preserving supported content, roles, order, and paired tool +calls/results, then loads them through ACP. +The pending input is submitted once, not also embedded in the loaded prefix. +Continuation without a new user request must preserve the existing provider's +continuation semantics rather than repeat an earlier request. +History is not flattened into a user-message memo. + +Native records are a derived provider representation. +The demonstrated encoder needs no seed response: record bookkeeping is authored +locally, while message content comes from JP. +Native storage formats are version-specific; their encoder and decoder need +fixtures and qualified runtime versions. +Opaque reasoning metadata follows existing Anthropic conversion rules, not +invented signatures or claims that every provider's reasoning is +interchangeable. + +Provider changes, replay, selected-turn forks, compaction, and attachment +changes all prepare the current Thread through this conversion. +Returning from an OpenAI turn therefore includes that turn without a manual +handoff: + +```sh +jp q -n --auth sub -m anthropic/claude-opus-5 "Review the design." +jp q --auth sub -m openai/gpt-6-astra "Check the assumptions." +jp q --auth sub -m anthropic/claude-opus-5 "Continue from that review." +``` + +The implementation can reuse native state only when it represents the current +Thread and configuration. +A saved session ID alone is insufficient. +Otherwise, create a separate native transcript and load it. +Keep immutable JP history separate from disposable provider files, preserve +input content when remapping record identifiers, and never edit a user's +unrelated Claude Code session. + +Import newly generated events once. +Load-time replay is historical observation, not new output or authorization to +execute a historical call. +Context isolation uses distinct ACP sessions for independent requests, including +auxiliary queries. +Tool callbacks and side queries must not share a mutex around one occupied +native session. +Conversation locking and durable writes remain JP's responsibility. + +### Tools, output, and model support + +The ACP flow supplies JP's MCP server with a stable tool namespace. +It disables Claude Code's native side-effectful tools and unconfigured MCP +servers, and suppresses optional hooks, skills, and background features through +qualified controls. +The runtime's actual tool surface is checked. +These controls are not an OS sandbox, and native runtime context must be +accounted for rather than mistaken for JP attachments. + +The hosted server executes through JP's policies: enablement, approval, argument +editing, tool options, access checks, inquiries, result editing, and recording. +ACP tool updates are observations. +The tested `_meta["claudecode/toolUseId"]` on MCP call requests correlates +execution with ACP's tool-call ID; request numbers or matching arguments are not +substitutes. +Preserve the distinction between requested and edited execution arguments. + +Sequential external tool dispatch is acceptable. +Correct pairing, JP's approval behavior, and isolation are not optional. +Forced tool selection retains JP's existing best-effort semantics; this flow +does not promise stronger enforcement. +Cancellation reaches pending interactions and running tools. +Disconnection after a possible side effect is not permission to repeat it +automatically. + +Use `ModelDetails.subscription` and the existing capability fields for the +selected flow's supported model set and controls. +Resolve canonical IDs against qualified model information, and retain the actual +response model in metadata. +Do not require ACP discovery for API-only requests or silently substitute a +different model. +Explicit unsupported controls need the existing capability handling, not silent +removal. +HTTP-specific transport settings remain scoped to the HTTP implementations. + +Apply the resolved system prompt and response schema when preparing a request. +The tested custom-prompt form uses `snapshot: false`; native prompt snapshots +must not suppress JP configuration changes. +Structured results arrive through the adapter's raw SDK result extension and +become JP structured responses. +Streamed text/thinking use JP's existing rendering. +A refusal maps to `FinishReason::Refused`, including its category when supplied; +an SDK result with `subtype: success` and `is_error: true` is not success. + +### Large tool results + +Claude Code can replace a large tool result with a file reference before the +next model request. +That is not acceptable merely because JP still stores the original: with native +file tools disabled, the model may not receive the data. + +The measured working configuration is: + +- `MAX_MCP_OUTPUT_TOKENS=100000` in the runtime environment. +- `_meta["anthropic/maxResultSizeChars"] = 500000` on each relevant JP tool's + `tools/list` entry. + +This preserves a 240,052-byte text result through the SDK-visible boundary and +lets the model answer from its footer. +The environment setting alone fails the same test. +Keep the text-preservation assertion; retrieving a substituted file through an +uncontrolled native tool is not an equivalent result. + +The [documented size override] has a maximum value of 500,000 characters. +This is an inline-text threshold for the result of one tool invocation, not a +limit on JP's stored conversation, the full request, or the model's context +window. +Larger results can still be produced, but the runtime can substitute a file +reference. +Multiple large results and other runtime context-management policies can impose +additional constraints. + +Handling results outside the qualified range remains an explicit compatibility +edge: establish a provider-controlled continuation/reconstruction mechanism that +preserves them, or agree a documented limitation before claiming parity. +Silent truncation, automatic `direct` fallback, and bypassing JP permissions are +not solutions. +A diagnostic avoids silent loss but is not proof that the original workflow is +supported. + +### Prompt caching and subscription usage + +Prompt caching is server-side reuse of an identical request prefix, not reuse of +a local session file. +[Claude Code's cache documentation] explains that matching requests can share a +cache across sessions. +Reconstructing a transcript therefore need not destroy caching, but changes in +rendered content can. + +The traces already demonstrate cache reads: an ordinary follow-up reads 836 +cached input tokens, and reconstructed-history requests each read 1,322. +These examples use different prompts/models and one-hour cache writes. +They prove reuse of some prefix, not equal cache efficiency or parity with +native continuation for an arbitrary JP Thread. + +Preserve stable tool names, definitions and ordering, message content, and +wire-visible tool-call IDs when the Thread is unchanged. +Avoid putting transient session IDs, listener addresses, or per-request +temporary working directories into the model-visible prefix. +Native file locations can vary without changing the agent's logical working +directory. +Account for runtime-added environment and git context when deciding whether a +prefix is stable. +Do not sacrifice current instructions or correct history to preserve a cache +entry. + +Honor `assistant.request.cache` through the qualified runtime controls: + +| JP policy | ACP flow mapping | +| --------------- | -------------------------------------------------------------------------------------------------------- | +| `off` | `DISABLE_PROMPT_CACHING=1`. | +| `short` | `CLAUDE_CODE_PROMPT_CACHE_TTL=5m`. | +| `long` | `CLAUDE_CODE_PROMPT_CACHE_TTL=1h`. | +| Custom duration | Existing Anthropic mapping: at least 30 minutes selects one hour; shorter durations select five minutes. | + +These published runtime controls require integration tests. +Isolate conflicting ambient runtime overrides; report a managed-policy conflict +rather than claim a JP setting was honored when it was not. +JP's default remains `short`, rather than silently adopting Claude Code's +subscription default of one hour. +JP-initiated auxiliary requests use their own resolved policy. +Cache breakpoint placement need not be byte-identical between flows, but +supported caching controls and unchanged-prefix reuse must remain useful. + +Record uncached input, cache creation, cache reads, and output separately. +Distinguish per-request usage from cumulative `modelUsage` snapshots and runtime +helper activity. +Switching models, accounts, or flows may change cache scope; sharing between +them is not guaranteed. +Configuration edits and compaction can legitimately invalidate a prefix. + +Anthropic's [usage guidance] identifies caching as a way to conserve plan +allowance. +For otherwise equivalent work, more cache hits and fewer repeated writes reduce +input-processing expense. +Cached context still occupies the context window, output/thinking still consumes +usage, and larger histories can consume more allowance even with a high hit +ratio. +API cache-price multipliers and the SDK's dollar estimate are not a published +formula for subscription window percentages. + +### Experimental evidence + +The September 2026 probes use adapter 0.76.0 and Claude Code 2.1.257 with +subscription authentication. +The probe harness records protocol traffic, exact outputs, native fixtures, and +failure details. +Representative retained run IDs are listed here; the `tmp/acp-probe/` artifacts +are investigation data, not a substitute for checked-in integration fixtures. + +| Observation | Run | +| ------------------------------------------------------------------------------------------- | ------------------ | +| Template-based history replacement and edited historical tool results, with no re-execution | `HYYmzJ`, `Ttcolz` | +| Canonical Opus 5 selection and seed-free native records | `ZjvkdU` | +| Denial, host-side argument/result editing, cancellation while a tool is blocked | `mz8fnc` | +| Changed system prompt/schema with retained invoice data | `GRQZsv` | +| Identical calls remain distinct and correctly paired | `6zuTpk` | +| Separate processes retain separate histories while one waits on a tool | `cZpjor` | +| Image input and exact color identification | `FcZXUF` | +| Complete large text result with both size controls | `K7dVmM` | + +The unsuccessful marker-based configuration request is a recorded provider +refusal, not evidence of a general reload failure. +The default-limit and environment-only large-result probes retain their failed +preservation checks. +No experiment establishes completed JP integration, universal runtime-version +compatibility, or a quantitative subscription-quota conversion. + +## Drawbacks + +ACP subscription usage adds Node and an external runtime, native transcript +format maintenance, and runtime behavior outside JP's direct control. +Hidden context and helper requests can increase allowance consumption. +Compatibility qualification must track the adapter and its bundled runtime +together. + +Changing the default subscription flow requires existing subscription users to +prepare that runtime or opt into `direct`. +Keeping direct access preserves a risky alternative that JP must label honestly +and maintain separately. + +## Alternatives + +**Keep direct as the default.** Requires fewer dependencies, but leaves the +policy risk on users who have not chosen it explicitly. + +**Expose an `acp` provider.** Useful for a generic external-agent product, but +unnecessary for selecting how this vendor serves subscription requests. +The Claude-specific implementation stays inside `anthropic`. + +**Resume the last native session or flatten JP history into a memo.** Neither +preserves normal provider switching and projected history. +Native transcript conversion provides the demonstrated alternative. + +**Implement the adapter in Rust immediately.** Removes Node but expands the +initial work. +A later replacement can use the same behavioral tests while continuing to run +the official Claude Code binary. + +## Non-Goals + +- Removing direct subscription access or changing API-key behavior. +- Adding a generic ACP provider or changing model-ID syntax. +- Replacing Claude Code's authentication, copying its credentials, or modifying + its binary. +- Implementing `jp provider llm auth` delegation or native-login profile mapping + in the first delivery. +- Giving tools weaker policies or adding a latency benchmark requirement. + +## Risks and Open Questions + +- **Large-result and aggregate limits:** qualify behavior outside the measured + fixture and resolve the handling decision above. +- **Cache preservation:** compare warm continuation, process restart, and + reconstruction of the same Thread within the TTL, holding directory, account, + model, effort, tool definitions, and input content fixed. + Measure the shared prefix's cache reads/writes; existing hits do not prove + equal cache reuse. + A separate comparison of actual plan usage needs a quiet account and no quota + reset during measurement; cache counters alone do not measure that deduction. +- **Runtime-added context and work:** identify what the qualified runtime adds + despite disabled discovery, and suppress or account for it without editing the + binary or pretending the Thread contains it. +- **Control and metadata fidelity:** finish mappings for reasoning, request + controls, attachments, abort/discard, and `--no-persist` against JP's actual + paths. + The small image probe is not historical binary-content coverage. +- **Version and policy changes:** publish the supported adapter/runtime + combinations. + Anthropic can change subscription allowances and permitted usage; an explicit + direct choice does not protect an account from enforcement. + +## Implementation Plan + +1. **Flow selection and compatibility.** Add the typed field, default and + migration diagnostics, isolate the retained HTTP implementations, and qualify + model/runtime support. + API construction must not initialize ACP. +2. **One vertical slice.** Convert a real Thread, run a subscription-backed + request, service [RFD 109]'s hosted tools concurrently with ACP, and record + through JP's actual stream/rendering path. + Give auxiliary requests isolated native state. +3. **Workflow and result parity.** Extend the provider-owned route tests from + [PR 1152]. + Cover alternating providers/flows, replay, forks, compaction, configuration + changes, tool edits, cancellation and refusals. + Resolve large results without weakening assertions or executing historical + calls. +4. **Caching and release qualification.** Add controlled cache comparisons, + usage accounting, runtime fixtures, and setup documentation. + Keep prompt correctness ahead of cache reuse; use subscription usage + observations rather than treating list-price dollars as quota units. + +These phases keep the initial delivery focused on the Anthropic subscription +flow. +Public auth-command integration and replacing Node are subsequent work. + +[Claude Code's cache documentation]: https://code.claude.com/docs/en/prompt-caching +[PR 1151]: https://github.com/dcdpr/jp/pull/1151 +[PR 1152]: https://github.com/dcdpr/jp/pull/1152 +[PR 1153]: https://github.com/dcdpr/jp/pull/1153 +[RFD 090]: 090-anthropic-subscription-auth-with-credential-fallback.md +[RFD 109]: 109-in-process-jp-mcp-server.md +[documented size override]: https://code.claude.com/docs/en/mcp#raise-the-limit-for-a-specific-tool +[subscription clarification]: https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan +[usage guidance]: https://code.claude.com/docs/en/costs#why-usage-climbs-in-a-long-session diff --git a/docs/ticket/0jwc9gr-tool-execution-tests-that-spawn-a-process-only-run-on-unix.md b/docs/ticket/0jwc9gr-tool-execution-tests-that-spawn-a-process-only-run-on-unix.md new file mode 100644 index 000000000..74ab8440d --- /dev/null +++ b/docs/ticket/0jwc9gr-tool-execution-tests-that-spawn-a-process-only-run-on-unix.md @@ -0,0 +1,63 @@ +# Tool-execution tests that spawn a process only run on Unix + +- **Status**: Todo +- **Kind**: Chore +- **Authors**: jp +- **Date**: 2026-09-14 +- **Implements**: 109 +- **Label**: domain=mcp +- **Label**: package=jp_cli +- **Label**: package=jp_mcp +- **Label**: type=task + +Seven tests covering the JP MCP Server's process-spawning paths are gated +`#[cfg(unix)]`, so on Windows the local-command path, the inquiry re-execution +proof, and every custom-formatter behaviour are untested. + +## The tests + +- `jp_mcp::server::service_tests` + - `local_inquiry_exits_and_runs_a_new_process_with_the_answer` + - `formatter_asks_for_visibility_and_waits_for_approval` + - `unattended_formatter_is_available_before_approval` + - `a_formatter_is_told_the_name_the_tool_runs_under` + - `hidden_presentation_never_executes_formatter` +- `jp_mcp::server::conformance_tests` + - `external_inquiry_reexecutes_with_host_answers_and_records_edited_output` +- `jp_cli::cmd::query::tool::coordinator_tests` + - `remembered_denial_does_not_run_http_argument_formatter` + +## Why they are gated + +Each configures a tool whose command is `{"program": "sh", "args": ["-c", +"