diff --git a/Cargo.lock b/Cargo.lock index 355ca98cc..1e13161e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,33 +21,50 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "0.10.4" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10eeef5e80864f9c3c148a3f395c3e35a66d37ec7561c7845b2bffae8e841759" +checksum = "eb5232c1162bcff4eafe378cc88a1ccaee2d49cedd9fe3d2517c5742bb748ced" dependencies = [ + "agent-client-protocol-derive", "agent-client-protocol-schema", - "anyhow", - "async-broadcast", - "async-trait", - "derive_more", + "async-process", + "blocking", "futures", - "log", + "futures-concurrency", + "rustc-hash", + "schemars 1.2.1", "serde", "serde_json", + "shell-words", + "tracing", + "uuid", + "windows-sys 0.61.2", +] + +[[package]] +name = "agent-client-protocol-derive" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cb3131da850c922c348b36a9b96684d69779c3a776e83a948ba0b10e77766bb" +dependencies = [ + "quote", + "syn", ] [[package]] name = "agent-client-protocol-schema" -version = "0.11.4" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca68e7e55681ce56546c0cecc6bc8f20493d24b44c6d93ec46174f310730bba2" +checksum = "b4e7eb6f1e554741f621ae4abb046d4fbb3cb88541bc599693ae7f9aa30b72eb" dependencies = [ "anyhow", "derive_more", - "schemars", + "schemars 1.2.1", "serde", "serde_json", + "serde_with", "strum", + "tracing", ] [[package]] @@ -125,17 +142,88 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] -name = "async-broadcast" -version = "0.7.2" +name = "async-channel" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" dependencies = [ - "event-listener", + "concurrent-queue", "event-listener-strategy", "futures-core", "pin-project-lite", ] +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -254,6 +342,19 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "blox-cli" version = "0.1.0" @@ -265,6 +366,15 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.12.1" @@ -526,6 +636,49 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -695,6 +848,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "fnv" version = "1.0.7" @@ -747,6 +906,19 @@ dependencies = [ "futures-sink", ] +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -770,6 +942,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -902,13 +1087,19 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.15.5" @@ -930,6 +1121,18 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "1.4.0" @@ -1158,6 +1361,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1195,6 +1404,17 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1454,6 +1674,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -1561,12 +1787,43 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -1588,6 +1845,20 @@ dependencies = [ "tempfile", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1597,6 +1868,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2008,6 +2285,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "1.2.1" @@ -2115,6 +2404,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -2145,6 +2435,38 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha2" version = "0.11.0" @@ -2367,6 +2689,36 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -2498,9 +2850,21 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -2717,7 +3081,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -2730,7 +3094,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -3101,7 +3465,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.14.0", "prettyplease", "syn", "wasm-metadata", @@ -3132,7 +3496,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -3151,7 +3515,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", diff --git a/apps/staged/src-tauri/Cargo.lock b/apps/staged/src-tauri/Cargo.lock index 5874474e8..c78d6c21c 100644 --- a/apps/staged/src-tauri/Cargo.lock +++ b/apps/staged/src-tauri/Cargo.lock @@ -82,33 +82,50 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "agent-client-protocol" -version = "0.10.4" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10eeef5e80864f9c3c148a3f395c3e35a66d37ec7561c7845b2bffae8e841759" +checksum = "eb5232c1162bcff4eafe378cc88a1ccaee2d49cedd9fe3d2517c5742bb748ced" dependencies = [ + "agent-client-protocol-derive", "agent-client-protocol-schema", - "anyhow", - "async-broadcast", - "async-trait", - "derive_more", + "async-process", + "blocking", "futures", - "log", + "futures-concurrency", + "rustc-hash", + "schemars 1.2.1", "serde", "serde_json", + "shell-words", + "tracing", + "uuid", + "windows-sys 0.61.2", +] + +[[package]] +name = "agent-client-protocol-derive" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cb3131da850c922c348b36a9b96684d69779c3a776e83a948ba0b10e77766bb" +dependencies = [ + "quote", + "syn 2.0.118", ] [[package]] name = "agent-client-protocol-schema" -version = "0.11.4" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca68e7e55681ce56546c0cecc6bc8f20493d24b44c6d93ec46174f310730bba2" +checksum = "b4e7eb6f1e554741f621ae4abb046d4fbb3cb88541bc599693ae7f9aa30b72eb" dependencies = [ "anyhow", "derive_more", "schemars 1.2.1", "serde", "serde_json", + "serde_with", "strum", + "tracing", ] [[package]] @@ -1711,6 +1728,19 @@ dependencies = [ "futures-sink", ] +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -3584,6 +3614,26 @@ dependencies = [ "libc", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -4634,6 +4684,7 @@ version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -4789,6 +4840,12 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "2.0.1" diff --git a/apps/staged/src-tauri/examples/acp_stream_probe.rs b/apps/staged/src-tauri/examples/acp_stream_probe.rs index 8c5f0bbef..7796f6d90 100644 --- a/apps/staged/src-tauri/examples/acp_stream_probe.rs +++ b/apps/staged/src-tauri/examples/acp_stream_probe.rs @@ -107,12 +107,13 @@ impl MessageWriter for ProbeWriter { ); } - async fn record_tool_result(&self, content: &str) { + async fn record_tool_result(&self, tool_call_id: &str, content: &str) { let mut state = self.state.lock().expect("probe state lock poisoned"); state.total_tool_results += 1; println!( - "[probe] tool_result #{}: {}", + "[probe] tool_result #{} id={}: {}", state.total_tool_results, + tool_call_id, Self::truncate(content) ); } diff --git a/apps/staged/src-tauri/src/agent/mod.rs b/apps/staged/src-tauri/src/agent/mod.rs index 975c7b6d8..ab3b7ac23 100644 --- a/apps/staged/src-tauri/src/agent/mod.rs +++ b/apps/staged/src-tauri/src/agent/mod.rs @@ -3,11 +3,13 @@ //! This module provides adapters between Staged's storage layer and the //! acp-client crate's generic interfaces. +use std::collections::{HashMap, HashSet}; + pub mod writer; pub use acp_client::{discover_providers, AcpDriver, AcpProviderInfo, AgentDriver}; -use crate::store::Store; +use crate::store::{MessageRole, SessionMessage, Store}; // Implement the acp_client::Store trait for our Store impl acp_client::Store for Store { @@ -25,7 +27,382 @@ impl acp_client::Store for Store { }) .map_err(|e| e.to_string()) } + + fn get_session_replay_boundaries( + &self, + session_id: &str, + ) -> Result, String> { + self.get_session_replay_messages(session_id) + .map(replay_boundaries_from_messages) + .map_err(|e| e.to_string()) + } } // Re-export writer for backward compatibility pub use writer::MessageWriter; + +fn replay_boundaries_from_messages( + messages: Vec, +) -> Vec { + let duplicate_projection_message_ids = acp_message_projection_duplicate_ids(&messages); + let mut boundaries: Vec = Vec::new(); + let mut message_id_indexes: HashMap = HashMap::new(); + + for message in messages { + if duplicate_projection_message_ids.contains(&message.id) { + continue; + } + + if is_acp_message_chunk_with_id(&message) { + let message_id = message.acp.acp_message_id.clone().unwrap_or_default(); + let content = acp_chunk_text(&message).unwrap_or_default(); + if let Some(index) = message_id_indexes.get(&message_id).copied() { + boundaries[index].content.push_str(&content); + } else { + let index = boundaries.len(); + message_id_indexes.insert(message_id.clone(), index); + boundaries.push(acp_client::ReplayBoundary { + role: message.role.as_str().to_string(), + content, + acp_message_id: Some(message_id), + acp_tool_call_id: None, + }); + } + continue; + } + + if is_hidden_acp_metadata(&message) { + continue; + } + + boundaries.push(acp_client::ReplayBoundary { + role: message.role.as_str().to_string(), + content: message.content, + acp_message_id: message.acp.acp_message_id, + acp_tool_call_id: message.acp.acp_tool_call_id, + }); + } + + boundaries +} + +#[derive(Debug)] +struct AcpMessageChunkProjection { + acp_message_id: String, + role: String, + content: String, + first_chunk_row_id: i64, + last_chunk_row_id: i64, +} + +fn acp_message_projection_duplicate_ids(messages: &[SessionMessage]) -> HashSet { + let projections = acp_message_chunk_projections(messages); + let mut duplicate_ids = HashSet::new(); + + for projection in projections { + if let Some(id) = + find_visible_projection_duplicate_id(messages, &projection, &duplicate_ids) + { + duplicate_ids.insert(id); + } + } + + duplicate_ids +} + +fn acp_message_chunk_projections(messages: &[SessionMessage]) -> Vec { + let mut projections: Vec = Vec::new(); + let mut projection_indexes: HashMap = HashMap::new(); + + for message in messages + .iter() + .filter(|message| is_acp_message_chunk_with_id(message)) + { + let message_id = message.acp.acp_message_id.clone().unwrap_or_default(); + let content = acp_chunk_text(message).unwrap_or_default(); + if let Some(index) = projection_indexes.get(&message_id).copied() { + projections[index].content.push_str(&content); + projections[index].last_chunk_row_id = message.id; + } else { + projection_indexes.insert(message_id.clone(), projections.len()); + projections.push(AcpMessageChunkProjection { + acp_message_id: message_id, + role: message.role.as_str().to_string(), + content, + first_chunk_row_id: message.id, + last_chunk_row_id: message.id, + }); + } + } + + projections +} + +fn find_visible_projection_duplicate_id( + messages: &[SessionMessage], + projection: &AcpMessageChunkProjection, + used_message_ids: &HashSet, +) -> Option { + if let Some(message) = messages.iter().find(|message| { + is_visible_assistant_or_user(message) + && !used_message_ids.contains(&message.id) + && message.role.as_str() == projection.role.as_str() + && message.acp.acp_message_id.as_deref() == Some(projection.acp_message_id.as_str()) + }) { + return Some(message.id); + } + + if projection.content.is_empty() { + return None; + } + + find_visible_projection_duplicate_by_content(messages, projection, used_message_ids) +} + +fn find_visible_projection_duplicate_by_content( + messages: &[SessionMessage], + projection: &AcpMessageChunkProjection, + used_message_ids: &HashSet, +) -> Option { + let candidates = messages.iter().filter(|message| { + is_visible_assistant_or_user(message) + && !used_message_ids.contains(&message.id) + && message.role.as_str() == projection.role.as_str() + && message.content == projection.content + }); + + let mut inside_span: Option<&SessionMessage> = None; + let mut preceding: Option<&SessionMessage> = None; + let mut following: Option<&SessionMessage> = None; + + for message in candidates { + if message.id < projection.first_chunk_row_id { + if match preceding { + Some(current) => message.id > current.id, + None => true, + } { + preceding = Some(message); + } + } else if message.id <= projection.last_chunk_row_id { + if match inside_span { + Some(current) => message.id < current.id, + None => true, + } { + inside_span = Some(message); + } + } else if match following { + Some(current) => message.id < current.id, + None => true, + } { + following = Some(message); + } + } + + inside_span + .or(preceding) + .or(following) + .map(|message| message.id) +} + +fn is_visible_assistant_or_user(message: &SessionMessage) -> bool { + matches!(message.role, MessageRole::Assistant | MessageRole::User) + && !is_hidden_acp_metadata(message) +} + +fn is_hidden_acp_metadata(message: &SessionMessage) -> bool { + message.content.is_empty() && message.acp.acp_event_kind.is_some() +} + +fn is_acp_message_chunk_with_id(message: &SessionMessage) -> bool { + matches!( + message.acp.acp_event_kind.as_deref(), + Some("agent_message_chunk" | "user_message_chunk") + ) && message.acp.acp_message_id.is_some() +} + +fn acp_chunk_text(message: &SessionMessage) -> Option { + message + .acp + .acp_content + .as_ref()? + .get("content")? + .get("text")? + .as_str() + .map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::AcpMessageMetadata; + + fn message( + id: i64, + role: MessageRole, + content: &str, + acp: AcpMessageMetadata, + ) -> SessionMessage { + SessionMessage { + id, + session_id: "session-1".to_string(), + role, + content: content.to_string(), + created_at: id, + image_ids: vec![], + acp, + } + } + + fn acp_text_chunk(event_kind: &str, message_id: &str, text: &str) -> AcpMessageMetadata { + AcpMessageMetadata { + acp_event_kind: Some(event_kind.to_string()), + acp_message_id: Some(message_id.to_string()), + acp_content: Some(serde_json::json!({ + "content": {"type": "text", "text": text} + })), + ..Default::default() + } + } + + #[test] + fn replay_boundaries_coalesce_acp_message_chunks_by_message_id() { + let boundaries = replay_boundaries_from_messages(vec![ + message(1, MessageRole::Assistant, "hello world", Default::default()), + message( + 2, + MessageRole::Assistant, + "", + acp_text_chunk("agent_message_chunk", "msg-1", "hello "), + ), + message( + 3, + MessageRole::Assistant, + "", + acp_text_chunk("agent_message_chunk", "msg-1", "world"), + ), + message( + 4, + MessageRole::ToolCall, + "Read file", + AcpMessageMetadata { + acp_tool_call_id: Some("tool-1".to_string()), + ..Default::default() + }, + ), + ]); + + assert_eq!(boundaries.len(), 2); + assert_eq!(boundaries[0].role, "assistant"); + assert_eq!(boundaries[0].content, "hello world"); + assert_eq!(boundaries[0].acp_message_id.as_deref(), Some("msg-1")); + assert_eq!(boundaries[1].role, "tool_call"); + assert_eq!(boundaries[1].acp_tool_call_id.as_deref(), Some("tool-1")); + } + + #[test] + fn replay_boundaries_keep_later_visible_rows_with_repeated_chunk_content() { + let boundaries = replay_boundaries_from_messages(vec![ + message(1, MessageRole::Assistant, "repeat", Default::default()), + message( + 2, + MessageRole::Assistant, + "", + acp_text_chunk("agent_message_chunk", "msg-1", "repeat"), + ), + message(3, MessageRole::Assistant, "repeat", Default::default()), + ]); + + assert_eq!(boundaries.len(), 2); + assert_eq!(boundaries[0].content, "repeat"); + assert_eq!(boundaries[0].acp_message_id.as_deref(), Some("msg-1")); + assert_eq!(boundaries[1].content, "repeat"); + assert_eq!(boundaries[1].acp_message_id, None); + } + + #[test] + fn replay_boundaries_keep_earlier_visible_rows_with_repeated_chunk_content() { + let boundaries = replay_boundaries_from_messages(vec![ + message(1, MessageRole::Assistant, "repeat", Default::default()), + message(2, MessageRole::Assistant, "repeat", Default::default()), + message( + 3, + MessageRole::Assistant, + "", + acp_text_chunk("agent_message_chunk", "msg-1", "repeat"), + ), + ]); + + assert_eq!(boundaries.len(), 2); + assert_eq!(boundaries[0].content, "repeat"); + assert_eq!(boundaries[0].acp_message_id, None); + assert_eq!(boundaries[1].content, "repeat"); + assert_eq!(boundaries[1].acp_message_id.as_deref(), Some("msg-1")); + } + + #[test] + fn replay_boundaries_prefer_acp_message_id_for_visible_projection() { + let boundaries = replay_boundaries_from_messages(vec![ + message( + 1, + MessageRole::Assistant, + "repeat", + AcpMessageMetadata { + acp_message_id: Some("msg-1".to_string()), + ..Default::default() + }, + ), + message(2, MessageRole::Assistant, "repeat", Default::default()), + message( + 3, + MessageRole::Assistant, + "", + acp_text_chunk("agent_message_chunk", "msg-1", "repeat"), + ), + ]); + + assert_eq!(boundaries.len(), 2); + assert_eq!(boundaries[0].content, "repeat"); + assert_eq!(boundaries[0].acp_message_id, None); + assert_eq!(boundaries[1].content, "repeat"); + assert_eq!(boundaries[1].acp_message_id.as_deref(), Some("msg-1")); + } + + #[test] + fn replay_boundaries_keep_visible_rows_without_acp_message_ids() { + let boundaries = replay_boundaries_from_messages(vec![message( + 1, + MessageRole::Assistant, + "legacy assistant text", + Default::default(), + )]); + + assert_eq!(boundaries.len(), 1); + assert_eq!(boundaries[0].role, "assistant"); + assert_eq!(boundaries[0].content, "legacy assistant text"); + assert_eq!(boundaries[0].acp_message_id, None); + } + + #[test] + fn replay_boundaries_keep_legacy_rows_in_mixed_sessions() { + let boundaries = replay_boundaries_from_messages(vec![ + message( + 1, + MessageRole::Assistant, + "legacy assistant text", + Default::default(), + ), + message( + 2, + MessageRole::Assistant, + "", + acp_text_chunk("agent_message_chunk", "msg-2", "new assistant text"), + ), + ]); + + assert_eq!(boundaries.len(), 2); + assert_eq!(boundaries[0].content, "legacy assistant text"); + assert_eq!(boundaries[0].acp_message_id, None); + assert_eq!(boundaries[1].content, "new assistant text"); + assert_eq!(boundaries[1].acp_message_id.as_deref(), Some("msg-2")); + } +} diff --git a/apps/staged/src-tauri/src/agent/writer.rs b/apps/staged/src-tauri/src/agent/writer.rs index 7cfddb09c..9941b60af 100644 --- a/apps/staged/src-tauri/src/agent/writer.rs +++ b/apps/staged/src-tauri/src/agent/writer.rs @@ -14,10 +14,14 @@ use std::time::{Duration, Instant}; use async_trait::async_trait; use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; -use crate::store::{MessageRole, Store}; +use crate::store::{AcpMessageMetadata, MessageRole, Store}; -use acp_client::strip_code_fences; +use acp_client::{ + autoapprove_permission_decision, strip_code_fences, AcpEventMetadata, AcpInitializeMetadata, + AcpPermissionDecision, AcpPermissionRequest, AcpToolCallMetadata, +}; /// Minimum interval between DB flushes for streaming text. Chunks accumulate /// in memory and are written at most this often, reducing mutex contention @@ -41,11 +45,11 @@ pub struct MessageWriter { last_flush_at: Mutex, /// Maps external tool-call IDs → (DB row ID, last-known title). tool_call_rows: Mutex>, - /// DB row id of the currently streaming tool result. + /// Maps external tool-call IDs -> DB row ID for streaming tool results. /// - /// ACP can send multiple content updates for one tool call; we update - /// the same row instead of inserting duplicates. - current_tool_result_msg_id: Mutex>, + /// ACP can send interleaved content updates for multiple tool calls; each + /// tool call updates its own row instead of sharing one global result row. + tool_result_rows: Mutex>, } /// Strip backticks from agent-provided tool-call titles. @@ -64,6 +68,38 @@ fn format_tool_call_content(title: &str, raw_input: Option<&serde_json::Value>) } } +fn acp_tool_call_metadata(metadata: AcpToolCallMetadata) -> AcpMessageMetadata { + AcpMessageMetadata { + acp_event_kind: metadata.event_kind, + acp_message_id: metadata.message_id, + acp_tool_call_id: metadata.tool_call_id, + acp_tool_kind: metadata.tool_kind, + acp_tool_status: metadata.tool_status, + acp_raw_input: metadata.raw_input, + acp_raw_output: metadata.raw_output, + acp_content: metadata.content, + acp_locations: metadata.locations, + ..Default::default() + } +} + +fn acp_event_metadata(metadata: AcpEventMetadata) -> AcpMessageMetadata { + AcpMessageMetadata { + acp_event_kind: metadata.event_kind, + acp_message_id: metadata.message_id, + acp_content: metadata.content, + acp_usage: metadata.usage, + ..Default::default() + } +} + +fn acp_event_role(metadata: &AcpMessageMetadata) -> MessageRole { + match metadata.acp_event_kind.as_deref() { + Some("user_message_chunk") => MessageRole::User, + _ => MessageRole::Assistant, + } +} + impl MessageWriter { pub fn new(session_id: String, store: Arc) -> Self { Self { @@ -73,7 +109,7 @@ impl MessageWriter { current_text: Mutex::new(String::new()), last_flush_at: Mutex::new(Instant::now()), tool_call_rows: Mutex::new(HashMap::new()), - current_tool_result_msg_id: Mutex::new(None), + tool_result_rows: Mutex::new(HashMap::new()), } } @@ -116,7 +152,6 @@ impl MessageWriter { raw_input: Option<&serde_json::Value>, ) { self.finalize().await; - *self.current_tool_result_msg_id.lock().await = None; let title = sanitize_title(title); let content = format_tool_call_content(&title, raw_input); @@ -167,10 +202,10 @@ impl MessageWriter { } /// Record the result/output of a tool call. - pub async fn record_tool_result(&self, content: &str) { + pub async fn record_tool_result(&self, tool_call_id: &str, content: &str) { let content = strip_code_fences(content); - let mut current_result_id = self.current_tool_result_msg_id.lock().await; - if let Some(id) = *current_result_id { + let mut result_rows = self.tool_result_rows.lock().await; + if let Some(id) = result_rows.get(tool_call_id).copied() { let _ = self.store.update_message_content(id, &content); return; } @@ -180,7 +215,14 @@ impl MessageWriter { .add_session_message(&self.session_id, MessageRole::ToolResult, &content) { Ok(id) => { - *current_result_id = Some(id); + result_rows.insert(tool_call_id.to_string(), id); + let metadata = AcpMessageMetadata { + acp_tool_call_id: Some(tool_call_id.to_string()), + ..Default::default() + }; + if let Err(e) = self.store.update_message_acp_metadata(id, &metadata) { + log::error!("Failed to persist ACP tool-result metadata: {e}"); + } } Err(e) => log::error!("Failed to insert tool_result message: {e}"), } @@ -259,8 +301,117 @@ impl acp_client::MessageWriter for MessageWriter { .await } - async fn record_tool_result(&self, content: &str) { - self.record_tool_result(content).await + async fn record_tool_result(&self, tool_call_id: &str, content: &str) { + self.record_tool_result(tool_call_id, content).await + } + + async fn on_session_info_update(&self, info: &acp_client::SessionInfoUpdate) { + let metadata = AcpMessageMetadata { + acp_event_kind: Some("session_info_update".to_string()), + acp_session_info: serde_json::to_value(info).ok(), + ..Default::default() + }; + if let Err(e) = self + .store + .add_acp_metadata_message(&self.session_id, &metadata) + { + log::error!("Failed to persist ACP session info update: {e}"); + } + } + + async fn on_model_state_update(&self, state: &acp_client::SessionModelState) { + let metadata = AcpMessageMetadata { + acp_event_kind: Some("session_mode_state".to_string()), + acp_session_mode_state: serde_json::to_value(state).ok(), + ..Default::default() + }; + if let Err(e) = self + .store + .add_acp_metadata_message(&self.session_id, &metadata) + { + log::error!("Failed to persist ACP session mode state: {e}"); + } + } + + async fn on_config_option_update(&self, options: &[acp_client::SessionConfigOption]) { + if options.is_empty() { + return; + } + + let metadata = AcpMessageMetadata { + acp_event_kind: Some("config_options_update".to_string()), + acp_config_options: serde_json::to_value(options).ok(), + ..Default::default() + }; + if let Err(e) = self + .store + .add_acp_metadata_message(&self.session_id, &metadata) + { + log::error!("Failed to persist ACP config options update: {e}"); + } + } + + async fn on_initialize(&self, metadata: &AcpInitializeMetadata) { + let metadata = AcpMessageMetadata { + acp_event_kind: Some("initialize".to_string()), + acp_protocol_version: Some(metadata.protocol_version.clone()), + acp_agent_capabilities: metadata.agent_capabilities.clone(), + acp_auth_methods: metadata.auth_methods.clone(), + acp_agent_info: metadata.agent_info.clone(), + ..Default::default() + }; + if let Err(e) = self + .store + .add_acp_metadata_message(&self.session_id, &metadata) + { + log::error!("Failed to persist ACP initialization metadata: {e}"); + } + } + + async fn record_tool_call_metadata(&self, metadata: AcpToolCallMetadata) { + let tool_call_id = metadata.tool_call_id.clone(); + let metadata = acp_tool_call_metadata(metadata); + let row_id = if let Some(tool_call_id) = tool_call_id { + let rows = self.tool_call_rows.lock().await; + rows.get(&tool_call_id).map(|(row_id, _)| *row_id) + } else { + None + }; + + let result = match row_id { + Some(row_id) => self.store.update_message_acp_metadata(row_id, &metadata), + None => self + .store + .add_acp_metadata_message(&self.session_id, &metadata) + .map(|_| ()), + }; + + if let Err(e) = result { + log::error!("Failed to persist ACP tool-call metadata: {e}"); + } + } + + async fn record_acp_event_metadata(&self, metadata: AcpEventMetadata) { + let metadata = acp_event_metadata(metadata); + let role = acp_event_role(&metadata); + if let Err(e) = + self.store + .add_acp_metadata_message_with_role(&self.session_id, role, &metadata) + { + log::error!("Failed to persist ACP event metadata: {e}"); + } + } + + async fn request_permission( + &self, + request: AcpPermissionRequest, + cancel_token: CancellationToken, + ) -> AcpPermissionDecision { + if cancel_token.is_cancelled() { + AcpPermissionDecision::Cancelled + } else { + autoapprove_permission_decision(&request) + } } } @@ -271,6 +422,10 @@ mod tests { use super::MessageWriter; use crate::store::{MessageRole, Session, Store}; + use acp_client::{ + AcpPermissionDecision, AcpPermissionOption, AcpPermissionOptionKind, AcpPermissionRequest, + }; + use tokio_util::sync::CancellationToken; fn setup_writer() -> (Arc, String, MessageWriter) { let store = Arc::new(Store::in_memory().expect("in-memory store")); @@ -280,6 +435,98 @@ mod tests { (store, session.id, writer) } + fn permission_request(session_id: &str, request_id: &str) -> AcpPermissionRequest { + AcpPermissionRequest { + request_id: request_id.to_string(), + session_id: session_id.to_string(), + tool_call_id: "tc-permission".to_string(), + tool_title: Some("Edit src/lib.rs".to_string()), + tool_kind: Some("edit".to_string()), + tool_status: Some("pending".to_string()), + raw_input: Some(serde_json::json!({"path": "src/lib.rs"})), + raw_output: None, + content: None, + locations: None, + options: vec![ + AcpPermissionOption { + option_id: "allow-once".to_string(), + name: "Allow once".to_string(), + kind: AcpPermissionOptionKind::AllowOnce, + }, + AcpPermissionOption { + option_id: "reject-once".to_string(), + name: "Deny".to_string(), + kind: AcpPermissionOptionKind::RejectOnce, + }, + ], + raw_request: None, + } + } + + #[tokio::test] + async fn permission_request_autoapproves_without_ui_metadata() { + let (store, session_id, writer) = setup_writer(); + let request = permission_request(&session_id, "autoapprove-request"); + let cancel_token = CancellationToken::new(); + + let decision = ::request_permission( + &writer, + request, + cancel_token, + ) + .await; + + assert_eq!( + decision, + AcpPermissionDecision::Selected { + option_id: "allow-once".to_string() + } + ); + let metadata = store + .get_session_acp_metadata_messages(&session_id) + .expect("metadata"); + assert!(metadata.is_empty()); + } + + #[tokio::test] + async fn permission_request_prefers_allow_option_over_first_option() { + let (_store, session_id, writer) = setup_writer(); + let mut request = permission_request(&session_id, "allow-request"); + request.options.reverse(); + let cancel_token = CancellationToken::new(); + + let decision = ::request_permission( + &writer, + request, + cancel_token, + ) + .await; + + assert_eq!( + decision, + AcpPermissionDecision::Selected { + option_id: "allow-once".to_string() + } + ); + } + + #[tokio::test] + async fn permission_request_returns_cancelled_when_already_cancelled() { + let (_store, session_id, writer) = setup_writer(); + let request = permission_request(&session_id, "cancel-request"); + let cancel_token = CancellationToken::new(); + cancel_token.cancel(); + + let decision = ::request_permission( + &writer, + request, + cancel_token, + ) + .await; + + assert_eq!(decision, AcpPermissionDecision::Cancelled); + } + #[tokio::test] async fn record_tool_result_updates_existing_row_for_streaming_updates() { let (store, session_id, writer) = setup_writer(); @@ -287,8 +534,8 @@ mod tests { writer .record_tool_call("tc-1", "Run echo hello", None) .await; - writer.record_tool_result("first chunk").await; - writer.record_tool_result("second chunk").await; + writer.record_tool_result("tc-1", "first chunk").await; + writer.record_tool_result("tc-1", "second chunk").await; let messages = store .get_session_messages(&session_id) @@ -297,6 +544,38 @@ mod tests { assert_eq!(messages[0].role, MessageRole::ToolCall); assert_eq!(messages[1].role, MessageRole::ToolResult); assert_eq!(messages[1].content, "second chunk"); + assert_eq!(messages[1].acp.acp_tool_call_id.as_deref(), Some("tc-1")); + } + + #[tokio::test] + async fn record_tool_result_keeps_interleaved_tool_updates_separate() { + let (store, session_id, writer) = setup_writer(); + + writer + .record_tool_call("tc-1", "Run first command", None) + .await; + writer.record_tool_result("tc-1", "first initial").await; + writer + .record_tool_call("tc-2", "Run second command", None) + .await; + writer.record_tool_result("tc-2", "second initial").await; + writer.record_tool_result("tc-1", "first final").await; + writer.record_tool_result("tc-2", "second final").await; + + let messages = store + .get_session_messages(&session_id) + .expect("query messages"); + assert_eq!(messages.len(), 4); + assert_eq!(messages[0].role, MessageRole::ToolCall); + assert_eq!(messages[0].content, "Run first command"); + assert_eq!(messages[1].role, MessageRole::ToolResult); + assert_eq!(messages[1].content, "first final"); + assert_eq!(messages[1].acp.acp_tool_call_id.as_deref(), Some("tc-1")); + assert_eq!(messages[2].role, MessageRole::ToolCall); + assert_eq!(messages[2].content, "Run second command"); + assert_eq!(messages[3].role, MessageRole::ToolResult); + assert_eq!(messages[3].content, "second final"); + assert_eq!(messages[3].acp.acp_tool_call_id.as_deref(), Some("tc-2")); } #[tokio::test] @@ -361,4 +640,119 @@ mod tests { assert_eq!(parsed["name"], "Read file"); assert_eq!(parsed["input"]["path"], "bar.rs"); } + + #[tokio::test] + async fn record_tool_call_metadata_updates_existing_tool_call_row() { + let (store, session_id, writer) = setup_writer(); + + writer.record_tool_call("tc-meta", "Read file", None).await; + ::record_tool_call_metadata( + &writer, + acp_client::AcpToolCallMetadata { + event_kind: Some("tool_call".to_string()), + tool_call_id: Some("tc-meta".to_string()), + tool_kind: Some("read".to_string()), + tool_status: Some("completed".to_string()), + raw_input: Some(serde_json::json!({"path": "src/lib.rs"})), + ..Default::default() + }, + ) + .await; + + let messages = store + .get_session_messages(&session_id) + .expect("query messages"); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].role, MessageRole::ToolCall); + assert_eq!(messages[0].acp.acp_event_kind.as_deref(), Some("tool_call")); + assert_eq!(messages[0].acp.acp_tool_call_id.as_deref(), Some("tc-meta")); + assert_eq!(messages[0].acp.acp_tool_kind.as_deref(), Some("read")); + assert_eq!( + messages[0].acp.acp_raw_input.as_ref().unwrap()["path"], + "src/lib.rs" + ); + } + + #[tokio::test] + async fn initialize_metadata_is_persisted_as_hidden_row() { + let (store, session_id, writer) = setup_writer(); + + ::on_initialize( + &writer, + &acp_client::AcpInitializeMetadata { + protocol_version: "1".to_string(), + agent_capabilities: Some(serde_json::json!({ + "promptCapabilities": {"image": false} + })), + auth_methods: Some(serde_json::json!([ + {"id": "default", "name": "Default"} + ])), + agent_info: Some(serde_json::json!({ + "name": "codex", + "version": "0.1.0" + })), + }, + ) + .await; + + assert!( + store.get_session_messages(&session_id).unwrap().is_empty(), + "initialization metadata should not appear in transcript rows" + ); + + let metadata = store + .get_session_acp_initialization(&session_id) + .unwrap() + .expect("initialization metadata"); + assert_eq!(metadata.acp_protocol_version.as_deref(), Some("1")); + assert_eq!( + metadata.acp_agent_capabilities.unwrap()["promptCapabilities"]["image"], + false + ); + assert_eq!(metadata.acp_agent_info.unwrap()["name"], "codex"); + } + + #[tokio::test] + async fn acp_event_metadata_is_persisted_as_hidden_row() { + let (store, session_id, writer) = setup_writer(); + + ::record_acp_event_metadata( + &writer, + acp_client::AcpEventMetadata { + event_kind: Some("agent_message_chunk".to_string()), + message_id: Some("assistant-msg-1".to_string()), + content: Some(serde_json::json!({ + "content": {"type": "text", "text": "hello"} + })), + usage: Some(serde_json::json!({ + "totalTokens": 12, + "inputTokens": 5, + "outputTokens": 7 + })), + }, + ) + .await; + + assert!( + store.get_session_messages(&session_id).unwrap().is_empty(), + "ACP event metadata should not appear in transcript rows" + ); + + let metadata_rows = store + .get_session_acp_metadata_messages(&session_id) + .unwrap(); + assert_eq!(metadata_rows.len(), 1); + assert_eq!( + metadata_rows[0].acp.acp_event_kind.as_deref(), + Some("agent_message_chunk") + ); + assert_eq!( + metadata_rows[0].acp.acp_message_id.as_deref(), + Some("assistant-msg-1") + ); + assert_eq!( + metadata_rows[0].acp.acp_usage.as_ref().unwrap()["outputTokens"], + 7 + ); + } } diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 02685a574..cc113ea53 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -2313,6 +2313,8 @@ pub fn run() { session_commands::get_session, session_commands::get_session_messages, session_commands::get_session_messages_since, + session_commands::get_session_acp_metadata_messages, + session_commands::get_session_acp_initialization, session_commands::count_assistant_messages_after, session_commands::start_session, session_commands::resume_session, diff --git a/apps/staged/src-tauri/src/pikchr_subsession.rs b/apps/staged/src-tauri/src/pikchr_subsession.rs index cf4a91832..991d4450f 100644 --- a/apps/staged/src-tauri/src/pikchr_subsession.rs +++ b/apps/staged/src-tauri/src/pikchr_subsession.rs @@ -237,7 +237,7 @@ impl acp_client::MessageWriter for CapturingWriter { ) { } - async fn record_tool_result(&self, _content: &str) {} + async fn record_tool_result(&self, _tool_call_id: &str, _content: &str) {} } #[cfg(test)] @@ -288,7 +288,7 @@ box "Sink = NO-OP (default / external clone)" "no socket, no Block deps → buil writer: &Arc, _cancel_token: &CancellationToken, agent_session_id: Option<&str>, - ) -> Result<(), String> { + ) -> Result { let idx = { let mut calls = self.calls.lock().unwrap(); let idx = *calls; @@ -311,7 +311,7 @@ box "Sink = NO-OP (default / external clone)" "no socket, no Block deps → buil let reply = self.replies.get(idx).cloned().unwrap_or_default(); writer.append_text(&reply).await; writer.finalize().await; - Ok(()) + Ok(acp_client::AgentRunOutcome::Completed) } } diff --git a/apps/staged/src-tauri/src/project_mcp.rs b/apps/staged/src-tauri/src/project_mcp.rs index 2a19d27ef..1079dae83 100644 --- a/apps/staged/src-tauri/src/project_mcp.rs +++ b/apps/staged/src-tauri/src/project_mcp.rs @@ -1039,6 +1039,7 @@ mod tests { content: "start".to_string(), created_at: 106, image_ids: vec![], + acp: Default::default(), }, SessionMessage { id: 2, @@ -1047,6 +1048,7 @@ mod tests { content: "Working\nthrough change".to_string(), created_at: 107, image_ids: vec![], + acp: Default::default(), }, SessionMessage { id: 3, @@ -1055,6 +1057,7 @@ mod tests { content: "cargo test".to_string(), created_at: 108, image_ids: vec![], + acp: Default::default(), }, SessionMessage { id: 4, @@ -1063,6 +1066,7 @@ mod tests { content: "tests still running\n123".to_string(), created_at: 109, image_ids: vec![], + acp: Default::default(), }, ]; diff --git a/apps/staged/src-tauri/src/session_commands.rs b/apps/staged/src-tauri/src/session_commands.rs index 643ca70c3..fe0e6a0d3 100644 --- a/apps/staged/src-tauri/src/session_commands.rs +++ b/apps/staged/src-tauri/src/session_commands.rs @@ -356,6 +356,26 @@ pub fn get_session_messages_since( .map_err(|e| e.to_string()) } +#[tauri::command] +pub fn get_session_acp_metadata_messages( + store: tauri::State<'_, Mutex>>>, + session_id: String, +) -> Result, String> { + get_store(&store)? + .get_session_acp_metadata_messages(&session_id) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn get_session_acp_initialization( + store: tauri::State<'_, Mutex>>>, + session_id: String, +) -> Result, String> { + get_store(&store)? + .get_session_acp_initialization(&session_id) + .map_err(|e| e.to_string()) +} + #[tauri::command] pub fn count_assistant_messages_after( store: tauri::State<'_, Mutex>>>, @@ -537,7 +557,7 @@ pub async fn resume_session( // For remote branches, resolve the actual workspace path so the remote // agent starts in the correct repo directory. - let remote_working_dir = if let Some(ref branch) = branch_from_id { + let remote_working_dir = if let Some(ref branch) = linked_branch { if branch.workspace_name.is_some() { let ws_name = branch.workspace_name.as_deref().unwrap().to_string(); let store_for_resolve = Arc::clone(&store); diff --git a/apps/staged/src-tauri/src/session_runner.rs b/apps/staged/src-tauri/src/session_runner.rs index 8bd59acda..0f91b200c 100644 --- a/apps/staged/src-tauri/src/session_runner.rs +++ b/apps/staged/src-tauri/src/session_runner.rs @@ -45,7 +45,7 @@ use tauri::AppHandle; use tokio::io::{AsyncRead, AsyncReadExt}; use tokio_util::sync::CancellationToken; -use acp_client::{McpServer, McpServerHttp}; +use acp_client::{AgentRunOutcome, McpServer, McpServerHttp}; use crate::actions::{ActionExecutor, ActionRegistry}; use crate::agent::{AcpDriver, AgentDriver, MessageWriter}; @@ -650,7 +650,9 @@ pub fn start_session( match validate_latest_session_pikchr(&store, &config.session_id)? { LatestAssistantPikchrValidation::SkippedFinalMessage | LatestAssistantPikchrValidation::NoPikchr - | LatestAssistantPikchrValidation::Valid => return Ok(()), + | LatestAssistantPikchrValidation::Valid => { + return Ok(AgentRunOutcome::Completed); + } LatestAssistantPikchrValidation::Invalid(error) => { if repair_attempts >= PIKCHR_VALIDATION_MAX_REPAIR_ATTEMPTS { return Err(format!( @@ -707,10 +709,14 @@ pub fn start_session( // If the session was cancelled and then deleted, these DB writes // are harmless no-ops — see module-level docs on the race. let (new_status, error_msg, completion_reason) = match result { - Ok(()) if cancel_token.is_cancelled() => { + Ok(AgentRunOutcome::Cancelled) if cancel_token.is_cancelled() => { + ("cancelled", None, cancellation_completion_reason.clone()) + } + Ok(AgentRunOutcome::Cancelled) => ("cancelled", None, CompletionReason::Interrupted), + Ok(AgentRunOutcome::Completed) if cancel_token.is_cancelled() => { ("cancelled", None, cancellation_completion_reason.clone()) } - Ok(()) => ("completed", None, CompletionReason::TurnComplete), + Ok(AgentRunOutcome::Completed) => ("completed", None, CompletionReason::TurnComplete), Err(ref e) if cancel_token.is_cancelled() => { log::info!( "Session {session_id_for_status} cancelled (error during teardown: {e})" @@ -854,8 +860,11 @@ enum LatestAssistantPikchrValidation { Invalid(crate::pikchr_validation::PikchrValidationError), } -fn should_validate_pikchr_after_turn(result: &Result<(), String>, is_cancelled: bool) -> bool { - result.is_ok() && !is_cancelled +fn should_validate_pikchr_after_turn( + result: &Result, + is_cancelled: bool, +) -> bool { + matches!(result, Ok(AgentRunOutcome::Completed)) && !is_cancelled } fn validate_latest_session_pikchr( @@ -2842,6 +2851,7 @@ mod tests { content: content.to_string(), created_at: 0, image_ids: vec![], + acp: Default::default(), } } @@ -2911,12 +2921,15 @@ mod tests { #[test] fn pikchr_validation_skips_failed_cancelled_and_interrupted_turns() { - let ok = Ok(()); + let completed = Ok(AgentRunOutcome::Completed); + let cancelled = Ok(AgentRunOutcome::Cancelled); let failed = Err("agent crashed".to_string()); - assert!(should_validate_pikchr_after_turn(&ok, false)); + assert!(should_validate_pikchr_after_turn(&completed, false)); + assert!(!should_validate_pikchr_after_turn(&cancelled, false)); assert!(!should_validate_pikchr_after_turn(&failed, false)); - assert!(!should_validate_pikchr_after_turn(&ok, true)); + assert!(!should_validate_pikchr_after_turn(&completed, true)); + assert!(!should_validate_pikchr_after_turn(&cancelled, true)); assert!(!should_validate_pikchr_after_turn(&failed, true)); } diff --git a/apps/staged/src-tauri/src/store/messages.rs b/apps/staged/src-tauri/src/store/messages.rs index 56b0ecfe8..1a8ce8d6d 100644 --- a/apps/staged/src-tauri/src/store/messages.rs +++ b/apps/staged/src-tauri/src/store/messages.rs @@ -1,10 +1,17 @@ //! Session message CRUD operations. -use rusqlite::params; +use rusqlite::{params, Row}; -use super::models::{MessageRole, SessionMessage}; +use super::models::{AcpMessageMetadata, MessageRole, SessionMessage}; use super::{now_timestamp, Store, StoreError}; +const SESSION_MESSAGE_COLUMNS: &str = "id, session_id, role, content, created_at, image_ids, + acp_event_kind, acp_protocol_version, acp_agent_capabilities, acp_auth_methods, + acp_agent_info, acp_message_id, acp_tool_call_id, acp_tool_kind, acp_tool_status, + acp_raw_input, acp_raw_output, acp_content, acp_locations, acp_usage, + acp_session_info, acp_config_options, acp_session_mode_state"; +const VISIBLE_MESSAGE_FILTER: &str = "NOT (content = '' AND acp_event_kind IS NOT NULL)"; + /// Parse a JSON array string into a Vec, returning an empty vec on /// NULL or invalid JSON. fn parse_image_ids(raw: Option) -> Vec { @@ -12,6 +19,46 @@ fn parse_image_ids(raw: Option) -> Vec { .unwrap_or_default() } +fn parse_json_value(raw: Option) -> Option { + raw.and_then(|s| serde_json::from_str::(&s).ok()) +} + +fn json_column(value: Option<&serde_json::Value>) -> Option { + value.and_then(|v| serde_json::to_string(v).ok()) +} + +fn session_message_from_row(row: &Row<'_>) -> rusqlite::Result { + let role_str: String = row.get(2)?; + let image_ids_raw: Option = row.get(5)?; + Ok(SessionMessage { + id: row.get(0)?, + session_id: row.get(1)?, + role: MessageRole::parse(&role_str).unwrap_or(MessageRole::User), + content: row.get(3)?, + created_at: row.get(4)?, + image_ids: parse_image_ids(image_ids_raw), + acp: AcpMessageMetadata { + acp_event_kind: row.get(6)?, + acp_protocol_version: row.get(7)?, + acp_agent_capabilities: parse_json_value(row.get(8)?), + acp_auth_methods: parse_json_value(row.get(9)?), + acp_agent_info: parse_json_value(row.get(10)?), + acp_message_id: row.get(11)?, + acp_tool_call_id: row.get(12)?, + acp_tool_kind: row.get(13)?, + acp_tool_status: row.get(14)?, + acp_raw_input: parse_json_value(row.get(15)?), + acp_raw_output: parse_json_value(row.get(16)?), + acp_content: parse_json_value(row.get(17)?), + acp_locations: parse_json_value(row.get(18)?), + acp_usage: parse_json_value(row.get(19)?), + acp_session_info: parse_json_value(row.get(20)?), + acp_config_options: parse_json_value(row.get(21)?), + acp_session_mode_state: parse_json_value(row.get(22)?), + }, + }) +} + impl Store { pub fn add_session_message( &self, @@ -58,22 +105,36 @@ impl Store { session_id: &str, ) -> Result, StoreError> { let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT id, session_id, role, content, created_at, image_ids - FROM session_messages WHERE session_id = ?1 ORDER BY id ASC", - )?; - let rows = stmt.query_map(params![session_id], |row| { - let role_str: String = row.get(2)?; - let image_ids_raw: Option = row.get(5)?; - Ok(SessionMessage { - id: row.get(0)?, - session_id: row.get(1)?, - role: MessageRole::parse(&role_str).unwrap_or(MessageRole::User), - content: row.get(3)?, - created_at: row.get(4)?, - image_ids: parse_image_ids(image_ids_raw), - }) - })?; + let sql = format!( + "SELECT {SESSION_MESSAGE_COLUMNS} + FROM session_messages + WHERE session_id = ?1 AND {VISIBLE_MESSAGE_FILTER} + ORDER BY id ASC" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(params![session_id], session_message_from_row)?; + rows.collect::, _>>().map_err(Into::into) + } + + /// Return visible transcript rows plus hidden ACP rows that can identify + /// replay boundaries. This keeps resume matching on `session_messages` + /// without exposing metadata-only rows to the legacy transcript path. + pub fn get_session_replay_messages( + &self, + session_id: &str, + ) -> Result, StoreError> { + let conn = self.conn.lock().unwrap(); + let sql = format!( + "SELECT {SESSION_MESSAGE_COLUMNS} + FROM session_messages + WHERE session_id = ?1 + AND ({VISIBLE_MESSAGE_FILTER} + OR acp_message_id IS NOT NULL + OR acp_tool_call_id IS NOT NULL) + ORDER BY id ASC" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(params![session_id], session_message_from_row)?; rows.collect::, _>>().map_err(Into::into) } @@ -87,6 +148,180 @@ impl Store { Ok(()) } + /// Attach ACP metadata to an existing session message without changing its + /// legacy transcript projection. `None` fields preserve existing values. + pub fn update_message_acp_metadata( + &self, + id: i64, + metadata: &AcpMessageMetadata, + ) -> Result<(), StoreError> { + let raw_input = json_column(metadata.acp_raw_input.as_ref()); + let raw_output = json_column(metadata.acp_raw_output.as_ref()); + let content = json_column(metadata.acp_content.as_ref()); + let locations = json_column(metadata.acp_locations.as_ref()); + let usage = json_column(metadata.acp_usage.as_ref()); + let agent_capabilities = json_column(metadata.acp_agent_capabilities.as_ref()); + let auth_methods = json_column(metadata.acp_auth_methods.as_ref()); + let agent_info = json_column(metadata.acp_agent_info.as_ref()); + let session_info = json_column(metadata.acp_session_info.as_ref()); + let config_options = json_column(metadata.acp_config_options.as_ref()); + let session_mode_state = json_column(metadata.acp_session_mode_state.as_ref()); + + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE session_messages + SET acp_event_kind = COALESCE(?1, acp_event_kind), + acp_protocol_version = COALESCE(?2, acp_protocol_version), + acp_agent_capabilities = COALESCE(?3, acp_agent_capabilities), + acp_auth_methods = COALESCE(?4, acp_auth_methods), + acp_agent_info = COALESCE(?5, acp_agent_info), + acp_message_id = COALESCE(?6, acp_message_id), + acp_tool_call_id = COALESCE(?7, acp_tool_call_id), + acp_tool_kind = COALESCE(?8, acp_tool_kind), + acp_tool_status = COALESCE(?9, acp_tool_status), + acp_raw_input = COALESCE(?10, acp_raw_input), + acp_raw_output = COALESCE(?11, acp_raw_output), + acp_content = COALESCE(?12, acp_content), + acp_locations = COALESCE(?13, acp_locations), + acp_usage = COALESCE(?14, acp_usage), + acp_session_info = COALESCE(?15, acp_session_info), + acp_config_options = COALESCE(?16, acp_config_options), + acp_session_mode_state = COALESCE(?17, acp_session_mode_state) + WHERE id = ?18", + params![ + metadata.acp_event_kind.as_deref(), + metadata.acp_protocol_version.as_deref(), + agent_capabilities, + auth_methods, + agent_info, + metadata.acp_message_id.as_deref(), + metadata.acp_tool_call_id.as_deref(), + metadata.acp_tool_kind.as_deref(), + metadata.acp_tool_status.as_deref(), + raw_input, + raw_output, + content, + locations, + usage, + session_info, + config_options, + session_mode_state, + id + ], + )?; + Ok(()) + } + + /// Insert an ACP metadata-only row. Existing transcript reads hide these + /// rows while keeping the raw ACP information in `session_messages`. + pub fn add_acp_metadata_message( + &self, + session_id: &str, + metadata: &AcpMessageMetadata, + ) -> Result { + self.add_acp_metadata_message_with_role(session_id, MessageRole::Assistant, metadata) + } + + /// Insert an ACP metadata-only row with an explicit role. This is used for + /// hidden ACP events whose source role matters, such as user message chunks. + pub fn add_acp_metadata_message_with_role( + &self, + session_id: &str, + role: MessageRole, + metadata: &AcpMessageMetadata, + ) -> Result { + let raw_input = json_column(metadata.acp_raw_input.as_ref()); + let raw_output = json_column(metadata.acp_raw_output.as_ref()); + let content = json_column(metadata.acp_content.as_ref()); + let locations = json_column(metadata.acp_locations.as_ref()); + let usage = json_column(metadata.acp_usage.as_ref()); + let agent_capabilities = json_column(metadata.acp_agent_capabilities.as_ref()); + let auth_methods = json_column(metadata.acp_auth_methods.as_ref()); + let agent_info = json_column(metadata.acp_agent_info.as_ref()); + let session_info = json_column(metadata.acp_session_info.as_ref()); + let config_options = json_column(metadata.acp_config_options.as_ref()); + let session_mode_state = json_column(metadata.acp_session_mode_state.as_ref()); + + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO session_messages ( + session_id, role, content, created_at, image_ids, + acp_event_kind, acp_protocol_version, acp_agent_capabilities, + acp_auth_methods, acp_agent_info, acp_message_id, + acp_tool_call_id, acp_tool_kind, acp_tool_status, + acp_raw_input, acp_raw_output, acp_content, acp_locations, + acp_usage, acp_session_info, acp_config_options, + acp_session_mode_state + ) + VALUES (?1, ?2, '', ?3, NULL, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)", + params![ + session_id, + role.as_str(), + now_timestamp(), + metadata.acp_event_kind.as_deref(), + metadata.acp_protocol_version.as_deref(), + agent_capabilities, + auth_methods, + agent_info, + metadata.acp_message_id.as_deref(), + metadata.acp_tool_call_id.as_deref(), + metadata.acp_tool_kind.as_deref(), + metadata.acp_tool_status.as_deref(), + raw_input, + raw_output, + content, + locations, + usage, + session_info, + config_options, + session_mode_state + ], + )?; + Ok(conn.last_insert_rowid()) + } + + /// Return rows carrying ACP metadata, including rows hidden from the legacy + /// transcript projection. + pub fn get_session_acp_metadata_messages( + &self, + session_id: &str, + ) -> Result, StoreError> { + let conn = self.conn.lock().unwrap(); + let sql = format!( + "SELECT {SESSION_MESSAGE_COLUMNS} + FROM session_messages + WHERE session_id = ?1 AND acp_event_kind IS NOT NULL + ORDER BY id ASC" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(params![session_id], session_message_from_row)?; + rows.collect::, _>>().map_err(Into::into) + } + + /// Return the latest ACP initialization metadata row for a session. + /// + /// This exposes negotiated provider/protocol/capability data without + /// including metadata-only rows in the legacy transcript projection. + pub fn get_session_acp_initialization( + &self, + session_id: &str, + ) -> Result, StoreError> { + let conn = self.conn.lock().unwrap(); + let sql = format!( + "SELECT {SESSION_MESSAGE_COLUMNS} + FROM session_messages + WHERE session_id = ?1 AND acp_event_kind = 'initialize' + ORDER BY id DESC + LIMIT 1" + ); + let mut stmt = conn.prepare(&sql)?; + let mut rows = stmt.query(params![session_id])?; + match rows.next()? { + Some(row) => Ok(Some(session_message_from_row(row)?.acp)), + None => Ok(None), + } + } + /// Count assistant messages created after a given timestamp. pub fn count_assistant_messages_after( &self, @@ -96,7 +331,10 @@ impl Store { let conn = self.conn.lock().unwrap(); let count: i64 = conn.query_row( "SELECT COUNT(*) FROM session_messages - WHERE session_id = ?1 AND role = 'assistant' AND created_at > ?2", + WHERE session_id = ?1 + AND role = 'assistant' + AND created_at > ?2 + AND NOT (content = '' AND acp_event_kind IS NOT NULL)", params![session_id, after_timestamp], |row| row.get(0), )?; @@ -111,22 +349,16 @@ impl Store { since_id: i64, ) -> Result, StoreError> { let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT id, session_id, role, content, created_at, image_ids - FROM session_messages WHERE session_id = ?1 AND id >= ?2 ORDER BY id ASC", - )?; - let rows = stmt.query_map(params![session_id, since_id], |row| { - let role_str: String = row.get(2)?; - let image_ids_raw: Option = row.get(5)?; - Ok(SessionMessage { - id: row.get(0)?, - session_id: row.get(1)?, - role: MessageRole::parse(&role_str).unwrap_or(MessageRole::User), - content: row.get(3)?, - created_at: row.get(4)?, - image_ids: parse_image_ids(image_ids_raw), - }) - })?; + let sql = format!( + "SELECT {SESSION_MESSAGE_COLUMNS} + FROM session_messages + WHERE session_id = ?1 + AND id >= ?2 + AND {VISIBLE_MESSAGE_FILTER} + ORDER BY id ASC" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(params![session_id, since_id], session_message_from_row)?; let result: Vec = rows.collect::, _>>()?; Ok(result) } diff --git a/apps/staged/src-tauri/src/store/migration_tests.rs b/apps/staged/src-tauri/src/store/migration_tests.rs index 2589c078d..eb74784be 100644 --- a/apps/staged/src-tauri/src/store/migration_tests.rs +++ b/apps/staged/src-tauri/src/store/migration_tests.rs @@ -145,13 +145,18 @@ fn test_store_bootstraps_fresh_database_with_baseline_migration() { ) .unwrap(); - assert_eq!(version, 16); + assert_eq!(version, 18); assert_eq!(app_version, super::APP_VERSION); assert!(table_exists(&conn, "projects")); assert!(table_exists(&conn, "project_notes")); assert!(table_exists(&conn, "images")); assert!(column_exists(&conn, "comments", "note_session_id")); assert!(column_exists(&conn, "comments", "commit_session_id")); + assert!(column_exists( + &conn, + "session_messages", + "acp_agent_capabilities" + )); let trigger_count: i64 = conn .query_row( @@ -181,6 +186,14 @@ fn test_store_repairs_github_comment_tracking_user_version() { ); INSERT INTO app_metadata (id, app_version) VALUES (1, '0.2.9'); CREATE TABLE sessions (id TEXT PRIMARY KEY); + CREATE TABLE session_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL, + image_ids TEXT DEFAULT NULL + ); CREATE TABLE repo_badges ( github_repo TEXT NOT NULL, subpath TEXT NOT NULL DEFAULT '', @@ -207,8 +220,13 @@ fn test_store_repairs_github_comment_tracking_user_version() { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 16); + assert_eq!(version, 18); assert!(column_exists(&conn, "sessions", "pipeline")); + assert!(column_exists( + &conn, + "session_messages", + "acp_agent_capabilities" + )); cleanup_db(&path); } @@ -229,6 +247,14 @@ fn test_store_repairs_pipeline_user_version() { id TEXT PRIMARY KEY, pipeline TEXT ); + CREATE TABLE session_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL, + image_ids TEXT DEFAULT NULL + ); CREATE TABLE repo_badges ( github_repo TEXT NOT NULL, subpath TEXT NOT NULL DEFAULT '', @@ -250,10 +276,15 @@ fn test_store_repairs_pipeline_user_version() { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 16); + assert_eq!(version, 18); assert!(column_exists(&conn, "comments", "github_comment_id")); assert!(column_exists(&conn, "comments", "github_comment_type")); assert!(column_exists(&conn, "comments", "github_comment_stale")); + assert!(column_exists( + &conn, + "session_messages", + "acp_agent_capabilities" + )); cleanup_db(&path); } diff --git a/apps/staged/src-tauri/src/store/migrations/0017-session-message-acp-metadata/up.sql b/apps/staged/src-tauri/src/store/migrations/0017-session-message-acp-metadata/up.sql new file mode 100644 index 000000000..d4d867eea --- /dev/null +++ b/apps/staged/src-tauri/src/store/migrations/0017-session-message-acp-metadata/up.sql @@ -0,0 +1,18 @@ +ALTER TABLE session_messages ADD COLUMN acp_event_kind TEXT DEFAULT NULL; +ALTER TABLE session_messages ADD COLUMN acp_message_id TEXT DEFAULT NULL; +ALTER TABLE session_messages ADD COLUMN acp_tool_call_id TEXT DEFAULT NULL; +ALTER TABLE session_messages ADD COLUMN acp_tool_kind TEXT DEFAULT NULL; +ALTER TABLE session_messages ADD COLUMN acp_tool_status TEXT DEFAULT NULL; +ALTER TABLE session_messages ADD COLUMN acp_raw_input TEXT DEFAULT NULL; +ALTER TABLE session_messages ADD COLUMN acp_raw_output TEXT DEFAULT NULL; +ALTER TABLE session_messages ADD COLUMN acp_content TEXT DEFAULT NULL; +ALTER TABLE session_messages ADD COLUMN acp_locations TEXT DEFAULT NULL; +ALTER TABLE session_messages ADD COLUMN acp_usage TEXT DEFAULT NULL; +ALTER TABLE session_messages ADD COLUMN acp_session_info TEXT DEFAULT NULL; +ALTER TABLE session_messages ADD COLUMN acp_config_options TEXT DEFAULT NULL; +ALTER TABLE session_messages ADD COLUMN acp_session_mode_state TEXT DEFAULT NULL; + +CREATE INDEX idx_session_messages_acp_message + ON session_messages(session_id, acp_message_id); +CREATE INDEX idx_session_messages_acp_tool_call + ON session_messages(session_id, acp_tool_call_id); diff --git a/apps/staged/src-tauri/src/store/migrations/0018-session-message-acp-initialization/up.sql b/apps/staged/src-tauri/src/store/migrations/0018-session-message-acp-initialization/up.sql new file mode 100644 index 000000000..d9cc8a48a --- /dev/null +++ b/apps/staged/src-tauri/src/store/migrations/0018-session-message-acp-initialization/up.sql @@ -0,0 +1,7 @@ +ALTER TABLE session_messages ADD COLUMN acp_protocol_version TEXT DEFAULT NULL; +ALTER TABLE session_messages ADD COLUMN acp_agent_capabilities TEXT DEFAULT NULL; +ALTER TABLE session_messages ADD COLUMN acp_auth_methods TEXT DEFAULT NULL; +ALTER TABLE session_messages ADD COLUMN acp_agent_info TEXT DEFAULT NULL; + +CREATE INDEX idx_session_messages_acp_event + ON session_messages(session_id, acp_event_kind); diff --git a/apps/staged/src-tauri/src/store/models.rs b/apps/staged/src-tauri/src/store/models.rs index 2c3ac4d3f..573d1594e 100644 --- a/apps/staged/src-tauri/src/store/models.rs +++ b/apps/staged/src-tauri/src/store/models.rs @@ -682,6 +682,51 @@ pub struct SessionMessage { /// Stored as a JSON array string in the DB, deserialized to a Vec here. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub image_ids: Vec, + #[serde(flatten)] + pub acp: AcpMessageMetadata, +} + +/// ACP metadata attached to a transcript row. +/// +/// These fields preserve richer ACP v1 events without changing the legacy +/// transcript projection consumed by the current UI. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AcpMessageMetadata { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp_event_kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp_protocol_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp_agent_capabilities: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp_auth_methods: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp_agent_info: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp_message_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp_tool_call_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp_tool_kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp_tool_status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp_raw_input: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp_raw_output: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp_content: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp_locations: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp_usage: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp_session_info: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp_config_options: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp_session_mode_state: Option, } // ============================================================================= diff --git a/apps/staged/src-tauri/src/store/tests.rs b/apps/staged/src-tauri/src/store/tests.rs index 18eb523e7..5caaa3aac 100644 --- a/apps/staged/src-tauri/src/store/tests.rs +++ b/apps/staged/src-tauri/src/store/tests.rs @@ -761,6 +761,163 @@ fn test_session_messages() { assert_eq!(since[1].id, id2); } +#[test] +fn test_acp_metadata_rows_are_hidden_from_legacy_session_messages() { + let store = Store::in_memory().unwrap(); + + let session = Session::new_running("test", Path::new("/tmp")); + store.create_session(&session).unwrap(); + + let visible_id = store + .add_session_message(&session.id, MessageRole::Assistant, "visible") + .unwrap(); + let metadata_id = store + .add_acp_metadata_message( + &session.id, + &AcpMessageMetadata { + acp_event_kind: Some("session_info_update".to_string()), + acp_session_info: Some(serde_json::json!({"title": "ACP title"})), + ..Default::default() + }, + ) + .unwrap(); + + let all = store.get_session_messages(&session.id).unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].id, visible_id); + assert_eq!(all[0].content, "visible"); + + let count = store + .count_assistant_messages_after(&session.id, 0) + .unwrap(); + assert_eq!(count, 1); + + let conn = store.conn.lock().unwrap(); + let raw_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM session_messages WHERE session_id = ?1", + [&session.id], + |row| row.get(0), + ) + .unwrap(); + let stored_info: String = conn + .query_row( + "SELECT acp_session_info FROM session_messages WHERE id = ?1", + [metadata_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(raw_count, 2); + assert_eq!(stored_info, r#"{"title":"ACP title"}"#); +} + +#[test] +fn test_acp_metadata_messages_query_includes_hidden_rows() { + let store = Store::in_memory().unwrap(); + + let session = Session::new_running("test", Path::new("/tmp")); + store.create_session(&session).unwrap(); + + store + .add_acp_metadata_message_with_role( + &session.id, + MessageRole::User, + &AcpMessageMetadata { + acp_event_kind: Some("user_message_chunk".to_string()), + acp_message_id: Some("user-msg-1".to_string()), + acp_content: Some(serde_json::json!({ + "content": {"type": "text", "text": "hello"} + })), + ..Default::default() + }, + ) + .unwrap(); + store + .add_acp_metadata_message( + &session.id, + &AcpMessageMetadata { + acp_event_kind: Some("usage_update".to_string()), + acp_usage: Some(serde_json::json!({ + "used": 42, + "size": 1000 + })), + ..Default::default() + }, + ) + .unwrap(); + + assert!( + store.get_session_messages(&session.id).unwrap().is_empty(), + "ACP metadata rows must stay hidden from legacy transcript reads" + ); + + let metadata_rows = store + .get_session_acp_metadata_messages(&session.id) + .unwrap(); + assert_eq!(metadata_rows.len(), 2); + assert_eq!(metadata_rows[0].role, MessageRole::User); + assert_eq!( + metadata_rows[0].acp.acp_message_id.as_deref(), + Some("user-msg-1") + ); + assert_eq!(metadata_rows[1].acp.acp_usage.as_ref().unwrap()["used"], 42); +} + +#[test] +fn test_acp_initialization_metadata_is_queryable() { + let store = Store::in_memory().unwrap(); + + let session = Session::new_running("test", Path::new("/tmp")); + store.create_session(&session).unwrap(); + + store + .add_acp_metadata_message( + &session.id, + &AcpMessageMetadata { + acp_event_kind: Some("initialize".to_string()), + acp_protocol_version: Some("1".to_string()), + acp_agent_capabilities: Some(serde_json::json!({ + "loadSession": true, + "promptCapabilities": {"image": true}, + "mcpCapabilities": {"http": true, "sse": false} + })), + acp_auth_methods: Some(serde_json::json!([ + {"id": "default", "name": "Default"} + ])), + acp_agent_info: Some(serde_json::json!({ + "name": "goose", + "version": "1.2.3" + })), + ..Default::default() + }, + ) + .unwrap(); + + assert!( + store.get_session_messages(&session.id).unwrap().is_empty(), + "initialization metadata must remain hidden from legacy transcript reads" + ); + + let metadata = store + .get_session_acp_initialization(&session.id) + .unwrap() + .expect("initialization metadata should be returned"); + assert_eq!(metadata.acp_event_kind.as_deref(), Some("initialize")); + assert_eq!(metadata.acp_protocol_version.as_deref(), Some("1")); + assert_eq!( + metadata.acp_agent_capabilities.as_ref().unwrap()["promptCapabilities"]["image"], + true + ); + assert_eq!( + metadata.acp_auth_methods.as_ref().unwrap()[0]["id"], + "default" + ); + assert_eq!( + metadata.acp_agent_info.as_ref().unwrap()["version"], + "1.2.3" + ); +} + #[test] fn test_count_assistant_messages_after() { let store = Store::in_memory().unwrap(); diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index d148a2876..5c974edc0 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -2790,6 +2790,22 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result { + let store = get_store(store_mutex)?; + let session_id: String = arg(&args, "sessionId")?; + let messages = store + .get_session_acp_metadata_messages(&session_id) + .map_err(|e| e.to_string())?; + Ok(serde_json::to_value(messages).unwrap()) + } + "get_session_acp_initialization" => { + let store = get_store(store_mutex)?; + let session_id: String = arg(&args, "sessionId")?; + let metadata = store + .get_session_acp_initialization(&session_id) + .map_err(|e| e.to_string())?; + Ok(serde_json::to_value(metadata).unwrap()) + } "start_session" => { let store = get_store(store_mutex)?; let prompt: String = arg(&args, "prompt")?; @@ -2915,7 +2931,7 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result { + return invokeCommand('get_session_acp_metadata_messages', { sessionId }); +} + export function countAssistantMessagesAfter( sessionId: string, afterTimestamp: number diff --git a/apps/staged/src/lib/features/sessions/SessionModal.svelte b/apps/staged/src/lib/features/sessions/SessionModal.svelte index bb7620469..a475fc477 100644 --- a/apps/staged/src/lib/features/sessions/SessionModal.svelte +++ b/apps/staged/src/lib/features/sessions/SessionModal.svelte @@ -33,6 +33,11 @@ import Send from '@lucide/svelte/icons/send'; import Copy from '@lucide/svelte/icons/copy'; import Check from '@lucide/svelte/icons/check'; + import Clock from '@lucide/svelte/icons/clock'; + import CircleAlert from '@lucide/svelte/icons/circle-alert'; + import CircleCheck from '@lucide/svelte/icons/circle-check'; + import CircleDot from '@lucide/svelte/icons/circle-dot'; + import CircleSlash from '@lucide/svelte/icons/circle-slash'; import ChevronRight from '@lucide/svelte/icons/chevron-right'; import ChevronDown from '@lucide/svelte/icons/chevron-down'; import Zap from '@lucide/svelte/icons/zap'; @@ -52,6 +57,7 @@ deleteImage, getImageData, getSession, + getSessionAcpMetadataMessages, getSessionMessages, getSessionMessagesSince, handleExternalLinkClick, @@ -72,15 +78,22 @@ isMaybeTextFile, insertFilePathsAtCursor, } from '../branches/branchCardHelpers'; + import { hasXmlBlocks, sessionEndMessage, stripXmlTags } from './sessionModalHelpers'; import { - groupByVerb, - verbGroupSummary, - hasXmlBlocks, - sessionEndMessage, - stripCodeFences, - stripXmlTags, - type VerbGroup, - } from './sessionModalHelpers'; + buildAcpTranscriptGroups, + diffsFromAcpContent, + displayLocations, + formatJson, + groupRichToolsByVerb, + latestAvailableCommands, + simpleUnifiedDiff, + terminalRefsFromAcpContent, + toolResultText, + type AcpCommand, + type AcpTranscriptEvent, + type AcpTranscriptGroup, + type RichToolItem, + } from './acpTranscript'; import { displayRootKey, normalizeDisplayRoots, @@ -152,6 +165,7 @@ let session = $state(null); let messages = $state([]); + let acpMetadataMessages = $state([]); let loading = $state(true); let error = $state(null); let cancelling = $state(false); @@ -164,8 +178,7 @@ let inputText = $state(''); let messageQueue = $state([]); let copiedId = $state(null); - let expandedTools = $state>(new Set()); - let expandedVerbGroups = $state>(new Set()); + let expandedTools = $state>(new Set()); let displayRoots = $state([]); let currentDisplayRootKey = ''; @@ -493,13 +506,15 @@ if (untrack(() => session?.id) !== sessionId) { session = null; messages = []; + acpMetadataMessages = []; } loading = true; error = null; try { - const [s, msgsResult] = await Promise.all([ + const [s, msgsResult, acpMsgs] = await Promise.all([ getSession(sessionId), getSessionMessages(sessionId), + getSessionAcpMetadataMessages(sessionId), ]); if (closed) return; if (!s) { @@ -517,6 +532,7 @@ }) .catch(() => {}); } + acpMetadataMessages = acpMsgs; } catch (e) { error = e instanceof Error ? e.message : String(e); } finally { @@ -542,16 +558,24 @@ // Incremental message fetch if (messages.length === 0) { - const { data: msgs } = await getSessionMessages(sessionId); + const [{ data: msgs }, acpMsgs] = await Promise.all([ + getSessionMessages(sessionId), + getSessionAcpMetadataMessages(sessionId), + ]); if (closed) return; + acpMetadataMessages = acpMsgs; if (msgs.length > 0) { messages = msgs; scrollToBottomIfNear(true); } } else { const lastId = messages[messages.length - 1].id; - const updated = await getSessionMessagesSince(sessionId, lastId); + const [updated, acpMsgs] = await Promise.all([ + getSessionMessagesSince(sessionId, lastId), + getSessionAcpMetadataMessages(sessionId), + ]); if (closed) return; + acpMetadataMessages = acpMsgs; if (updated.length > 0) { const prev = messages.slice(0, -1); messages = [...prev, ...updated]; @@ -730,24 +754,14 @@ // Tool call expand/collapse // ========================================================================= - function toggleTool(msgId: number) { + function toggleTool(key: string) { const next = new Set(expandedTools); - if (next.has(msgId)) { - next.delete(msgId); - } else { - next.add(msgId); - } - expandedTools = next; - } - - function toggleVerbGroup(key: string) { - const next = new Set(expandedVerbGroups); if (next.has(key)) { next.delete(key); } else { next.add(key); } - expandedVerbGroups = next; + expandedTools = next; } // ========================================================================= @@ -1064,104 +1078,131 @@ onClose(); } - /** Group consecutive tool_call / tool_result messages into pairs */ - type ToolPair = { - call: SessionMessage; - result: SessionMessage | null; - }; - - type MessageGroup = - | { type: 'user'; message: SessionMessage } - | { type: 'assistant'; message: SessionMessage } - | { type: 'tools'; pairs: ToolPair[] }; - - let grouped = $derived.by(() => { - const groups: MessageGroup[] = []; - let i = 0; - while (i < messages.length) { - const msg = messages[i]; - if (msg.role === 'user') { - groups.push({ type: 'user', message: msg }); - i++; - } else if (msg.role === 'assistant') { - groups.push({ type: 'assistant', message: msg }); - i++; - } else { - // tool_call / tool_result: collect into pairs - const pairs: ToolPair[] = []; - while ( - i < messages.length && - (messages[i].role === 'tool_call' || messages[i].role === 'tool_result') - ) { - if (messages[i].role === 'tool_call') { - const call = messages[i]; - i++; - // Check if next message is the matching result - let result: SessionMessage | null = null; - if (i < messages.length && messages[i].role === 'tool_result') { - result = messages[i]; - i++; - } - pairs.push({ call, result }); - } else { - // Orphan tool_result (shouldn't happen, but handle gracefully) - pairs.push({ - call: messages[i], - result: null, - }); - i++; - } - } - groups.push({ type: 'tools', pairs }); - } - } - return groups; + let grouped = $derived.by(() => + buildAcpTranscriptGroups(messages, acpMetadataMessages, displayRoots) + ); + let slashCommands = $derived.by(() => latestAvailableCommands(acpMetadataMessages)); + let slashQuery = $derived.by(() => { + const trimmed = inputText.trimStart(); + return trimmed.startsWith('/') ? trimmed.slice(1).toLowerCase() : null; + }); + let matchingSlashCommands = $derived.by(() => { + if (slashQuery === null) return []; + return slashCommands + .filter((command) => command.name.toLowerCase().includes(slashQuery)) + .slice(0, 6); }); let isPipelinePrelude = $derived(isLive && !!session?.pipeline && grouped.length === 0); - /** For each index in `grouped`, whether a user message exists at a later index. - * Pre-computed in O(N) so the template can do an O(1) lookup instead of - * scanning with `findIndex` per tool group (which was O(N²) total). */ - let hasUserAfter = $derived.by(() => { - const arr = new Array(grouped.length); - let seen = false; - for (let i = grouped.length - 1; i >= 0; i--) { - arr[i] = seen; - if (grouped[i].type === 'user') seen = true; + function insertSlashCommand(command: AcpCommand) { + inputText = `/${command.name}${command.inputHint ? ' ' : ''}`; + tick().then(() => { + inputEl?.focus(); + autoResize(); + }); + } + + function acpEventSummary(event: AcpTranscriptEvent): string { + if (event.kind === 'plan_update') { + const entries = arrayProp(event.content, 'entries'); + return entries.length === 1 ? '1 step' : `${entries.length} steps`; } - return arr; - }); + if (event.kind === 'available_commands_update') { + const commands = arrayProp(event.content, 'availableCommands'); + return commands.length === 1 ? '1 command' : `${commands.length} commands`; + } + if (event.kind === 'config_options_update') { + const options = Array.isArray(event.content) ? event.content : []; + return options.length === 1 ? '1 option' : `${options.length} options`; + } + return compactJsonSummary(event.content); + } - /** - * Pre-compute verb groups for every tools group in both tenses. - * `groupByVerb` is expensive (JSON.parse + string replace per tool call) so we - * cache both the past-tense and present-tense variants here. The template then - * picks the right variant with a cheap boolean lookup instead of re-running the - * heavy computation on every render. - */ - let verbGroupCache = $derived.by(() => { - const cache: { past: VerbGroup[]; present: VerbGroup[] }[] = []; - for (const group of grouped) { - if (group.type === 'tools') { - cache.push({ - past: groupByVerb(group.pairs, displayRoots, true), - present: groupByVerb(group.pairs, displayRoots, false), - }); - } else { - // Placeholder — tool-group index won't line up otherwise. - // We use a separate counter in the template instead. - cache.push({ past: [], present: [] }); - } + function planEntries(content: unknown): Record[] { + return arrayProp(content, 'entries'); + } + + function configOptions(content: unknown): Record[] { + return Array.isArray(content) + ? content.filter( + (item): item is Record => + !!item && typeof item === 'object' && !Array.isArray(item) + ) + : []; + } + + function availableCommands(content: unknown): AcpCommand[] { + const commands = arrayProp(content, 'availableCommands'); + return commands + .map((command) => { + const name = stringProp(command, 'name'); + if (!name) return null; + return { + name, + description: stringProp(command, 'description') ?? '', + inputHint: stringProp(objectProp(command, 'input'), 'hint'), + }; + }) + .filter((command): command is AcpCommand => command !== null); + } + + function optionCurrentValue(option: Record): string { + const currentValue = stringProp(option, 'currentValue'); + if (currentValue) return currentValue; + const select = objectProp(option, 'select'); + return stringProp(select, 'currentValue') ?? ''; + } + + function optionChoices(option: Record): { value: string; name: string }[] { + const options = arrayProp(option, 'options'); + if (options.length === 0) return []; + if (options.every((item) => arrayProp(item, 'options').length > 0)) { + return options.flatMap((group) => optionChoices(group)); } - return cache; - }); + return options + .map((item) => { + const value = stringProp(item, 'value'); + const name = stringProp(item, 'name') ?? value; + return value && name ? { value, name } : null; + }) + .filter((item): item is { value: string; name: string } => item !== null); + } + + function stringProp(value: unknown, key: string): string | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const prop = (value as Record)[key]; + return typeof prop === 'string' ? prop : null; + } + + function objectProp(value: unknown, key: string): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const prop = (value as Record)[key]; + return prop && typeof prop === 'object' && !Array.isArray(prop) + ? (prop as Record) + : null; + } + + function arrayProp(value: unknown, key: string): Record[] { + if (!value || typeof value !== 'object' || Array.isArray(value)) return []; + const prop = (value as Record)[key]; + return Array.isArray(prop) + ? prop.filter( + (item): item is Record => + !!item && typeof item === 'object' && !Array.isArray(item) + ) + : []; + } + + function compactJsonSummary(value: unknown): string { + const text = formatJson(value).replace(/\s+/g, ' ').trim(); + return text.length > 72 ? `${text.slice(0, 72)}…` : text; + } - /** Stable key for a message group — used to key the {#each} block for transitions. - * For tools groups, keys off the first pair — safe because the grouping logic - * in `grouped` always pushes at least one pair before creating a tools group. */ - function groupKey(group: MessageGroup): string { - return group.type === 'tools' ? `t-${group.pairs[0].call.id}` : `m-${group.message.id}`; + function groupKey(group: AcpTranscriptGroup): string { + if (group.type === 'tools') return `t-${group.items[0].key}`; + if (group.type === 'acp') return `a-${group.event.id}`; + return `m-${group.message.id}`; } /** Slide-in transition that is suppressed during the initial load. */ @@ -1173,6 +1214,113 @@ +{#snippet toolStatusIcon(statusTone: RichToolItem['statusTone'])} + {#if statusTone === 'running'} + + {:else if statusTone === 'success'} + + {:else if statusTone === 'danger'} + + {:else if statusTone === 'cancelled'} + + {:else} + + {/if} +{/snippet} + +{#snippet richToolCard(item: RichToolItem, nested: boolean)} + {@const isExpanded = expandedTools.has(item.key)} + {@const resultText = toolResultText(item)} + {@const rawInputText = formatJson(item.rawInput)} + {@const rawOutputText = formatJson(item.rawOutput)} + {@const diffs = diffsFromAcpContent(item.content, displayRoots)} + {@const locations = displayLocations(item.locations, displayRoots)} + {@const terminalRefs = terminalRefsFromAcpContent(item.content)} + {@const hasDetails = + !!resultText || + !!rawInputText || + !!rawOutputText || + diffs.length > 0 || + locations.length > 0 || + terminalRefs.length > 0} +
+ + +
hasDetails && toggleTool(item.key)} + > + + + {@render toolStatusIcon(item.statusTone)} + + {item.verb} + {#if item.detail} + {item.detail} + {/if} +
+ {#if isExpanded && hasDetails} +
+ {#if (item.verb === 'Ran' || item.verb === 'Running') && item.detail} +
$ {item.detail}
+ {/if} + {#if locations.length > 0} +
+ {#each locations as location} + {location} + {/each} +
+ {/if} + {#if rawInputText} +
Input
+
{rawInputText}
+ {/if} + {#each diffs as diff} +
{diff.path}
+
{simpleUnifiedDiff(diff)}
+ {/each} + {#if terminalRefs.length > 0} +
Terminal
+
+ {#each terminalRefs as terminalRef} + {terminalRef} + {/each} +
+ {/if} + {#if resultText} +
Output
+
{resultText}
+ {/if} + {#if rawOutputText && rawOutputText !== resultText} +
Raw output
+
{rawOutputText}
+ {/if} +
+ {#if item.statusTone === 'success'} + + {/if} + {item.statusLabel} +
+
+ {/if} +
+{/snippet} + !v && requestClose()}> {:else}
- {#each grouped as group, groupIdx (groupKey(group))} + {#each grouped as group (groupKey(group))}
{#if group.type === 'user'} {@const hasBlocks = cachedHasXmlBlocks(group.message.content)} @@ -1369,112 +1517,101 @@
- {:else} - {@const forcePastTense = !isLive || sending || hasUserAfter[groupIdx]} + {:else if group.type === 'tools'}
- {#each forcePastTense ? verbGroupCache[groupIdx].past : verbGroupCache[groupIdx].present as vg, vgIdx} - {#if vg.items.length === 1} - {@const item = vg.items[0]} - {@const isExpanded = expandedTools.has(item.pair.call.id)} -
- - -
item.pair.result && toggleTool(item.pair.call.id)} - > - - {item.verb} - {#if item.detail} - {item.detail} - {/if} -
- {#if isExpanded && item.pair.result} - {@const resultContent = stripCodeFences(item.pair.result.content)} -
- {#if (item.verb === 'Ran' || item.verb === 'Running') && item.detail} -
$ {item.detail}
- {/if} - {#if resultContent} -
{resultContent}
- {/if} -
- Success -
-
- {/if} -
+ {#each groupRichToolsByVerb(group.items) as verbGroup (verbGroup.key)} + {#if verbGroup.items.length === 1} + {@render richToolCard(verbGroup.items[0], false)} {:else} - {@const verbGroupKey = `${vg.items[0].pair.call.id}-${vg.verb}`} - {@const isGroupExpanded = expandedVerbGroups.has(verbGroupKey)} + {@const isGroupExpanded = expandedTools.has(verbGroup.key)}
toggleVerbGroup(verbGroupKey)} + onclick={() => toggleTool(verbGroup.key)} > - {vg.verb} - {verbGroupSummary(vg)} + + {@render toolStatusIcon(verbGroup.statusTone)} + + {verbGroup.verb} + {verbGroup.summary}
{#if isGroupExpanded}
- {#each vg.items as item} - {@const isExpanded = expandedTools.has(item.pair.call.id)} -
- - -
item.pair.result && toggleTool(item.pair.call.id)} - > - - {item.verb} - {#if item.detail} - {item.detail} - {/if} -
- {#if isExpanded && item.pair.result} - {@const resultContent = stripCodeFences(item.pair.result.content)} -
- {#if (item.verb === 'Ran' || item.verb === 'Running') && item.detail} -
$ {item.detail}
- {/if} - {#if resultContent} -
{resultContent}
- {/if} -
- Success -
-
- {/if} -
+ {#each verbGroup.items as item (item.key)} + {@render richToolCard(item, true)} {/each}
{/if} {/if} {/each}
+ {:else} + {@const event = group.event} + {@const isExpanded = expandedTools.has(`event:${event.id}`)} +
+
+ + +
toggleTool(`event:${event.id}`)}> + + {event.title} + {acpEventSummary(event)} +
+ {#if isExpanded} +
+ {#if event.kind === 'plan_update'} +
+ {#each planEntries(event.content) as entry} +
+ {stringProp(entry, 'status')} + {stringProp(entry, 'content')} +
+ {/each} +
+ {:else if event.kind === 'config_options_update'} +
+ {#each configOptions(event.content) as option} + + {/each} +
+ {:else if event.kind === 'available_commands_update'} +
+ {#each availableCommands(event.content) as command} + + {/each} +
+ {:else} +
{formatJson(event.content)}
+ {/if} +
+ {/if} +
+
{/if} {/each} @@ -1580,6 +1717,16 @@ {/each} {/if} + {#if matchingSlashCommands.length > 0 && !isLive} +
+ {#each matchingSlashCommands as command} + + {/each} +
+ {/if} {#if canAttachImages && replyImageIds.length > 0}
{#each replyImageIds as imageId} @@ -2022,6 +2169,32 @@ white-space: nowrap; } + .tool-status-dot { + display: inline-flex; + align-items: center; + justify-content: center; + width: 12px; + height: 12px; + flex-shrink: 0; + color: var(--text-faint); + } + + .tool-status-dot.status-running { + color: var(--ui-warning); + } + + .tool-status-dot.status-success { + color: var(--ui-success, var(--ui-accent)); + } + + .tool-status-dot.status-danger { + color: var(--ui-danger); + } + + .tool-status-dot.status-cancelled { + color: var(--text-muted); + } + .tool-args-preview { flex: 1; min-width: 0; @@ -2059,6 +2232,44 @@ word-break: break-word; } + .diff-output { + color: var(--text-primary); + } + + .tool-panel-label { + margin: 10px 0 4px; + color: var(--text-faint); + font-family: inherit; + font-size: calc(var(--size-xs) * 0.86); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + .tool-panel-label:first-child { + margin-top: 0; + } + + .tool-meta-row { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin: 4px 0 8px; + } + + .tool-chip { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + border: 1px solid var(--border-subtle); + border-radius: 6px; + padding: 2px 6px; + color: var(--text-muted); + font-family: inherit; + font-size: calc(var(--size-xs) * 0.88); + white-space: nowrap; + } + .tool-code-status { display: flex; align-items: center; @@ -2069,6 +2280,14 @@ color: var(--text-muted); } + .tool-code-status.status-danger { + color: var(--ui-danger); + } + + .tool-code-status.status-cancelled { + color: var(--text-faint); + } + .tool-code-block::-webkit-scrollbar { width: 4px; } @@ -2092,6 +2311,135 @@ padding: 4px 0; } + /* ACP metadata cards */ + .acp-event-row { + display: flex; + } + + .acp-event-card { + width: 100%; + min-width: 0; + overflow: hidden; + } + + .acp-event-header { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 0; + color: var(--text-muted); + font-size: var(--size-xs); + cursor: pointer; + } + + .acp-event-header:hover .acp-event-title { + text-decoration: underline; + } + + .acp-event-title { + flex-shrink: 0; + font-weight: 500; + } + + .acp-event-body { + margin-top: 4px; + border: 1px solid var(--border-subtle); + border-radius: 8px; + background: var(--bg-primary); + padding: 8px 10px; + font-size: var(--size-xs); + } + + .acp-event-body pre { + margin: 0; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + color: var(--text-muted); + font-family: 'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace; + font-size: calc(var(--size-xs) * 0.9); + line-height: 1.5; + } + + .plan-list, + .config-list, + .command-list { + display: flex; + flex-direction: column; + gap: 6px; + } + + .plan-entry { + display: flex; + gap: 8px; + align-items: flex-start; + color: var(--text-muted); + line-height: 1.35; + } + + .plan-status { + flex-shrink: 0; + min-width: 74px; + color: var(--text-faint); + font-size: calc(var(--size-xs) * 0.9); + text-transform: capitalize; + } + + .config-option { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(140px, 220px); + gap: 8px; + align-items: center; + color: var(--text-muted); + } + + .config-option select { + min-width: 0; + border: 1px solid var(--border-subtle); + border-radius: 6px; + background: var(--bg-chrome); + color: var(--text-muted); + padding: 3px 6px; + font: inherit; + } + + .command-row, + .slash-command-item { + display: grid; + grid-template-columns: minmax(96px, max-content) minmax(0, 1fr); + gap: 8px; + width: 100%; + align-items: baseline; + border: 0; + background: transparent; + color: var(--text-muted); + padding: 4px 6px; + text-align: left; + font: inherit; + cursor: pointer; + } + + .command-row:hover, + .slash-command-item:hover { + background: var(--bg-hover); + } + + .command-row span, + .slash-command-item span { + color: var(--text-primary); + font-family: 'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace; + font-size: calc(var(--size-xs) * 0.95); + } + + .command-row small, + .slash-command-item small { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-faint); + white-space: nowrap; + } + .note-followup-row { display: flex; justify-content: center; @@ -2116,6 +2464,18 @@ overflow: hidden; } + .slash-command-popover { + display: flex; + flex-direction: column; + gap: 0; + margin: 0 14px; + border: 1px solid var(--border-muted); + border-bottom: none; + border-radius: 10px 10px 0 0; + background: var(--bg-elevated); + overflow: hidden; + } + .queue-item { display: flex; align-items: center; diff --git a/apps/staged/src/lib/features/sessions/acpTranscript.test.ts b/apps/staged/src/lib/features/sessions/acpTranscript.test.ts new file mode 100644 index 000000000..66876aed8 --- /dev/null +++ b/apps/staged/src/lib/features/sessions/acpTranscript.test.ts @@ -0,0 +1,328 @@ +import { describe, expect, it } from 'vitest'; +import type { SessionMessage } from '../../types'; +import { + buildAcpTranscriptGroups, + groupRichToolsByVerb, + latestAvailableCommands, + type RichToolItem, +} from './acpTranscript'; + +function message( + overrides: Partial & Pick +): SessionMessage { + return { + sessionId: 'session-1', + content: overrides.content ?? '', + createdAt: overrides.id, + ...overrides, + }; +} + +describe('buildAcpTranscriptGroups', () => { + it('pairs tool results by ACP tool call id without relying on adjacency', () => { + const visible = [ + message({ id: 1, role: 'user', content: 'go' }), + message({ + id: 2, + role: 'tool_call', + content: JSON.stringify({ name: 'Read', input: { file_path: '/repo/src/main.rs' } }), + acpEventKind: 'tool_call', + acpToolCallId: 'tc-1', + }), + message({ id: 3, role: 'assistant', content: 'working' }), + message({ + id: 4, + role: 'tool_result', + content: 'done', + acpToolCallId: 'tc-1', + }), + ]; + + const groups = buildAcpTranscriptGroups(visible, [], '/repo'); + expect(groups.map((group) => group.type)).toEqual(['user', 'tools', 'assistant']); + const toolGroup = groups[1]; + expect(toolGroup.type).toBe('tools'); + if (toolGroup.type === 'tools') { + expect(toolGroup.items[0].result?.id).toBe(4); + expect(toolGroup.items[0].detail).toBe('src/main.rs'); + } + }); + + it('merges latest ACP tool status and raw output from metadata rows', () => { + const visible = [ + message({ + id: 1, + role: 'tool_call', + content: 'Run tests', + acpEventKind: 'tool_call', + acpToolCallId: 'tc-2', + acpToolStatus: 'pending', + acpRawInput: { command: 'npm test' }, + }), + ]; + const metadata = [ + message({ + id: 2, + role: 'assistant', + acpEventKind: 'tool_call_update', + acpToolCallId: 'tc-2', + acpToolStatus: 'failed', + acpRawOutput: { exitCode: 1 }, + }), + ]; + + const groups = buildAcpTranscriptGroups(visible, metadata); + const toolGroup = groups[0]; + expect(toolGroup.type).toBe('tools'); + if (toolGroup.type === 'tools') { + expect(toolGroup.items[0].status).toBe('failed'); + expect(toolGroup.items[0].rawOutput).toEqual({ exitCode: 1 }); + } + }); + + it('does not surface permission metadata as transcript events', () => { + const metadata = [ + message({ + id: 1, + role: 'assistant', + acpEventKind: 'permission_request', + acpContent: { + requestId: 'perm-1', + status: 'pending', + options: [{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }], + }, + }), + message({ + id: 2, + role: 'assistant', + acpEventKind: 'permission_response', + acpContent: { + requestId: 'perm-1', + status: 'selected', + selectedOptionId: 'allow-once', + }, + }), + ]; + + const groups = buildAcpTranscriptGroups([], metadata); + expect(groups).toEqual([]); + }); + + it('hides operational ACP metadata rows from the transcript', () => { + const metadata = [ + message({ + id: 1, + role: 'assistant', + acpEventKind: 'usage_update', + acpUsage: { inputTokens: 10, outputTokens: 5 }, + }), + message({ + id: 2, + role: 'assistant', + acpEventKind: 'prompt_response', + acpUsage: { inputTokens: 20, outputTokens: 8 }, + }), + message({ + id: 3, + role: 'assistant', + acpEventKind: 'config_options_update', + acpConfigOptions: [{ name: 'Model' }], + }), + message({ + id: 4, + role: 'assistant', + acpEventKind: 'session_mode_state', + acpSessionModeState: { currentModeId: 'default' }, + }), + message({ + id: 5, + role: 'assistant', + acpEventKind: 'current_mode_update', + acpContent: { currentModeId: 'default' }, + }), + message({ + id: 6, + role: 'assistant', + acpEventKind: 'available_commands_update', + acpContent: { availableCommands: [{ name: 'plan' }] }, + }), + ]; + + const groups = buildAcpTranscriptGroups([], metadata); + expect(groups).toEqual([]); + }); + + it('continues surfacing plan metadata rows', () => { + const groups = buildAcpTranscriptGroups( + [], + [ + message({ + id: 1, + role: 'assistant', + acpEventKind: 'plan_update', + acpContent: { entries: [{ content: 'Check UI', status: 'pending' }] }, + }), + ] + ); + + expect(groups).toHaveLength(1); + expect(groups[0].type).toBe('acp'); + }); +}); + +describe('groupRichToolsByVerb', () => { + it('collapses adjacent tools with the same verb', () => { + const groups = groupRichToolsByVerb([ + richTool({ key: 'tool:1', verb: 'Read', detail: 'src/a.ts' }), + richTool({ key: 'tool:2', verb: 'Read', detail: 'src/b.ts' }), + ]); + + expect(groups).toHaveLength(1); + expect(groups[0].verb).toBe('Read'); + expect(groups[0].summary).toBe('2 files'); + expect(groups[0].items.map((item) => item.detail)).toEqual(['src/a.ts', 'src/b.ts']); + }); + + it('keeps different adjacent verbs separate', () => { + const groups = groupRichToolsByVerb([ + richTool({ key: 'tool:1', verb: 'Read' }), + richTool({ key: 'tool:2', verb: 'Searched' }), + richTool({ key: 'tool:3', verb: 'Read' }), + ]); + + expect(groups.map((group) => group.verb)).toEqual(['Read', 'Searched', 'Read']); + expect(groups.map((group) => group.items)).toHaveLength(3); + }); + + it('does not merge same verbs across transcript group boundaries', () => { + const transcript = buildAcpTranscriptGroups( + [ + message({ + id: 1, + role: 'tool_call', + content: JSON.stringify({ name: 'Read', input: { file_path: '/repo/a.ts' } }), + }), + message({ id: 2, role: 'tool_result', content: 'a' }), + message({ id: 3, role: 'assistant', content: 'middle' }), + message({ + id: 4, + role: 'tool_call', + content: JSON.stringify({ name: 'Read', input: { file_path: '/repo/b.ts' } }), + }), + message({ id: 5, role: 'tool_result', content: 'b' }), + ], + [], + '/repo' + ); + const toolGroups = transcript.filter((group) => group.type === 'tools'); + + expect(transcript.map((group) => group.type)).toEqual(['tools', 'assistant', 'tools']); + expect(toolGroups).toHaveLength(2); + for (const group of toolGroups) { + if (group.type === 'tools') { + expect(groupRichToolsByVerb(group.items)).toHaveLength(1); + expect(group.items).toHaveLength(1); + } + } + }); + + it('keeps ACP status and structured metadata on grouped items', () => { + const transcript = buildAcpTranscriptGroups( + [ + message({ + id: 1, + role: 'tool_call', + content: JSON.stringify({ name: 'Read', input: { file_path: '/repo/a.ts' } }), + acpEventKind: 'tool_call', + acpToolCallId: 'tc-1', + acpToolStatus: 'completed', + }), + message({ + id: 2, + role: 'tool_call', + content: JSON.stringify({ name: 'Read', input: { file_path: '/repo/b.ts' } }), + acpEventKind: 'tool_call', + acpToolCallId: 'tc-2', + }), + ], + [ + message({ + id: 3, + role: 'assistant', + acpEventKind: 'tool_call_update', + acpToolCallId: 'tc-2', + acpToolStatus: 'failed', + acpRawInput: { file_path: '/repo/b.ts' }, + acpRawOutput: { error: 'missing' }, + acpContent: [{ type: 'content', content: { type: 'text', text: 'not found' } }], + acpLocations: [{ path: '/repo/b.ts', line: 12 }], + }), + ], + '/repo' + ); + const toolGroup = transcript.find((group) => group.type === 'tools'); + + expect(toolGroup?.type).toBe('tools'); + if (toolGroup?.type === 'tools') { + const groups = groupRichToolsByVerb(toolGroup.items); + expect(groups).toHaveLength(1); + expect(groups[0].statusTone).toBe('danger'); + expect(groups[0].items[1].status).toBe('failed'); + expect(groups[0].items[1].rawInput).toEqual({ file_path: '/repo/b.ts' }); + expect(groups[0].items[1].rawOutput).toEqual({ error: 'missing' }); + expect(groups[0].items[1].content).toEqual([ + { type: 'content', content: { type: 'text', text: 'not found' } }, + ]); + expect(groups[0].items[1].locations).toEqual([{ path: '/repo/b.ts', line: 12 }]); + } + }); +}); + +describe('latestAvailableCommands', () => { + it('extracts slash commands from the latest ACP command update', () => { + const commands = latestAvailableCommands([ + message({ + id: 1, + role: 'assistant', + acpEventKind: 'available_commands_update', + acpContent: { + availableCommands: [ + { + name: 'plan', + description: 'Create a plan', + input: { hint: 'goal' }, + }, + ], + }, + }), + ]); + + expect(commands).toEqual([ + { + name: 'plan', + description: 'Create a plan', + inputHint: 'goal', + }, + ]); + }); +}); + +function richTool( + overrides: Partial & Pick +): RichToolItem { + return { + call: message({ id: Number(overrides.key.replace(/\D/g, '')) || 1, role: 'tool_call' }), + result: null, + detail: '', + status: 'completed', + statusLabel: 'Succeeded', + statusTone: 'success', + toolCallId: null, + toolKind: null, + rawInput: undefined, + rawOutput: undefined, + content: undefined, + locations: undefined, + ...overrides, + }; +} diff --git a/apps/staged/src/lib/features/sessions/acpTranscript.ts b/apps/staged/src/lib/features/sessions/acpTranscript.ts new file mode 100644 index 000000000..7e1782313 --- /dev/null +++ b/apps/staged/src/lib/features/sessions/acpTranscript.ts @@ -0,0 +1,614 @@ +import type { SessionMessage } from '../../types'; +import type { DisplayRootInput } from './pathDisplayRoots'; +import { + formatToolDisplay, + makePathsRelative, + stripCodeFences, + verbGroupSummary, +} from './sessionModalHelpers'; + +export type ToolStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'cancelled'; + +export interface RichToolItem { + key: string; + call: SessionMessage; + result: SessionMessage | null; + verb: string; + detail: string; + status: ToolStatus; + statusLabel: string; + statusTone: 'muted' | 'running' | 'success' | 'danger' | 'cancelled'; + toolCallId: string | null; + toolKind: string | null; + rawInput: unknown; + rawOutput: unknown; + content: unknown; + locations: unknown; +} + +export interface RichToolVerbGroup { + key: string; + verb: string; + summary: string; + statusTone: RichToolItem['statusTone']; + items: RichToolItem[]; +} + +export type AcpTranscriptEventKind = + | 'plan_update' + | 'usage_update' + | 'prompt_response' + | 'config_options_update' + | 'session_mode_state' + | 'current_mode_update' + | 'available_commands_update' + | 'session_info_update'; + +export interface AcpTranscriptEvent { + id: number; + kind: AcpTranscriptEventKind; + title: string; + content: unknown; + message: SessionMessage; +} + +export type AcpTranscriptGroup = + | { type: 'user'; message: SessionMessage } + | { type: 'assistant'; message: SessionMessage } + | { type: 'tools'; items: RichToolItem[] } + | { type: 'acp'; event: AcpTranscriptEvent }; + +export interface AcpCommand { + name: string; + description: string; + inputHint: string | null; +} + +interface ToolAssembly { + key: string; + toolCallId: string | null; + positionId: number; + call: SessionMessage; + result: SessionMessage | null; + metadata: Partial< + Pick + > & { + status?: ToolStatus; + }; +} + +interface TimelineEntry { + id: number; + type: 'visible' | 'hidden-tool' | 'acp-event'; + message?: SessionMessage; + toolKey?: string; + event?: AcpTranscriptEvent; +} + +const TOOL_EVENT_KINDS = new Set(['tool_call', 'tool_call_update']); +const VISIBLE_STANDALONE_EVENT_KINDS = new Set([ + 'plan_update', + 'session_info_update', +]); + +export function buildAcpTranscriptGroups( + visibleMessages: SessionMessage[], + acpMetadataMessages: SessionMessage[], + displayRoots?: DisplayRootInput +): AcpTranscriptGroup[] { + const metadataRows = sortedUniqueMessages( + [...visibleMessages.filter(hasAcpMetadata), ...acpMetadataMessages].filter(hasAcpMetadata) + ); + const toolAssemblies = assembleTools(visibleMessages, metadataRows); + const assignedResultIds = new Set(); + for (const item of toolAssemblies.values()) { + if (item.result) assignedResultIds.add(item.result.id); + } + + const entries = buildTimelineEntries( + visibleMessages, + metadataRows, + toolAssemblies, + assignedResultIds + ); + const groups: AcpTranscriptGroup[] = []; + const emittedTools = new Set(); + + for (const entry of entries) { + if (entry.type === 'visible') { + const message = entry.message!; + if (message.role === 'user') { + pushNonToolGroup(groups, { type: 'user', message }); + } else if (message.role === 'assistant') { + pushNonToolGroup(groups, { type: 'assistant', message }); + } else if (message.role === 'tool_call') { + const key = toolKeyForMessage(message); + const assembly = toolAssemblies.get(key); + if (assembly && !emittedTools.has(key)) { + pushToolGroup(groups, richToolItem(assembly, displayRoots)); + emittedTools.add(key); + } + } else if (!assignedResultIds.has(message.id)) { + const key = `result:${message.id}`; + const assembly = toolAssemblies.get(key); + if (assembly && !emittedTools.has(key)) { + pushToolGroup(groups, richToolItem(assembly, displayRoots)); + emittedTools.add(key); + } + } + } else if (entry.type === 'hidden-tool') { + const key = entry.toolKey!; + const assembly = toolAssemblies.get(key); + if (assembly && !emittedTools.has(key)) { + pushToolGroup(groups, richToolItem(assembly, displayRoots)); + emittedTools.add(key); + } + } else if (entry.event) { + pushNonToolGroup(groups, { type: 'acp', event: entry.event }); + } + } + + return groups; +} + +export function latestAvailableCommands(metadataMessages: SessionMessage[]): AcpCommand[] { + const latest = [...metadataMessages] + .reverse() + .find((message) => message.acpEventKind === 'available_commands_update'); + const commands = arrayProp(latest?.acpContent, 'availableCommands'); + if (!Array.isArray(commands)) return []; + + return commands + .map((command) => { + const name = stringProp(command, 'name'); + if (!name) return null; + return { + name, + description: stringProp(command, 'description') ?? '', + inputHint: stringProp(objectProp(command, 'input'), 'hint'), + }; + }) + .filter((command): command is AcpCommand => command !== null); +} + +export function formatJson(value: unknown): string { + if (value === undefined || value === null) return ''; + if (typeof value === 'string') return value; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +export function textFromAcpContent(content: unknown): string { + if (!Array.isArray(content)) return ''; + const parts: string[] = []; + for (const item of content) { + const type = stringProp(item, 'type'); + if (type === 'content') { + const block = objectProp(item, 'content'); + if (stringProp(block, 'type') === 'text') { + const text = stringProp(block, 'text'); + if (text) parts.push(text); + } + } + } + return parts.join('\n').trim(); +} + +export interface AcpDiff { + path: string; + oldText: string | null; + newText: string; +} + +export function diffsFromAcpContent(content: unknown, displayRoots?: DisplayRootInput): AcpDiff[] { + if (!Array.isArray(content)) return []; + const diffs: AcpDiff[] = []; + for (const item of content) { + if (stringProp(item, 'type') !== 'diff') continue; + const path = stringProp(item, 'path'); + const newText = stringProp(item, 'newText'); + if (!path || newText === null) continue; + diffs.push({ + path: makePathsRelative(path, displayRoots), + oldText: stringProp(item, 'oldText'), + newText, + }); + } + return diffs; +} + +export function terminalRefsFromAcpContent(content: unknown): string[] { + if (!Array.isArray(content)) return []; + return content + .filter((item) => stringProp(item, 'type') === 'terminal') + .map((item) => stringProp(item, 'terminalId')) + .filter((id): id is string => !!id); +} + +export function groupRichToolsByVerb(items: RichToolItem[]): RichToolVerbGroup[] { + const groups: Array> = []; + for (const item of items) { + const last = groups[groups.length - 1]; + if (last?.verb === item.verb) { + last.items.push(item); + } else { + groups.push({ + key: `verb:${item.key}:${item.verb}`, + verb: item.verb, + items: [item], + }); + } + } + + return groups.map((group) => ({ + ...group, + summary: verbGroupSummary(group), + statusTone: groupStatusTone(group.items), + })); +} + +export function simpleUnifiedDiff(diff: AcpDiff): string { + const oldLines = (diff.oldText ?? '').split('\n'); + const newLines = diff.newText.split('\n'); + if (diff.oldText === null) { + return newLines.map((line) => `+${line}`).join('\n'); + } + if (diff.oldText === diff.newText) return diff.newText; + + const output: string[] = []; + const max = Math.max(oldLines.length, newLines.length); + for (let i = 0; i < max; i++) { + const oldLine = oldLines[i]; + const newLine = newLines[i]; + if (oldLine === newLine) { + if (oldLine !== undefined) output.push(` ${oldLine}`); + } else { + if (oldLine !== undefined) output.push(`-${oldLine}`); + if (newLine !== undefined) output.push(`+${newLine}`); + } + } + return output.join('\n'); +} + +function groupStatusTone(items: RichToolItem[]): RichToolItem['statusTone'] { + if (items.some((item) => item.statusTone === 'danger')) return 'danger'; + if (items.some((item) => item.statusTone === 'running')) return 'running'; + if (items.some((item) => item.statusTone === 'cancelled')) return 'cancelled'; + if (items.length > 0 && items.every((item) => item.statusTone === 'success')) return 'success'; + return 'muted'; +} + +function sortedUniqueMessages(messages: SessionMessage[]): SessionMessage[] { + const byId = new Map(); + for (const message of messages) byId.set(message.id, message); + return [...byId.values()].sort((a, b) => a.id - b.id); +} + +function hasAcpMetadata(message: SessionMessage): boolean { + return !!message.acpEventKind; +} + +function isHiddenMetadata(message: SessionMessage): boolean { + return message.content === '' && !!message.acpEventKind; +} + +function toolKeyForMessage(message: SessionMessage): string { + return message.acpToolCallId ? `tool:${message.acpToolCallId}` : `row:${message.id}`; +} + +function assembleTools( + visibleMessages: SessionMessage[], + metadataRows: SessionMessage[] +): Map { + const tools = new Map(); + const keyByToolCallId = new Map(); + + for (const message of visibleMessages) { + if (message.role !== 'tool_call') continue; + const key = toolKeyForMessage(message); + if (message.acpToolCallId) keyByToolCallId.set(message.acpToolCallId, key); + tools.set(key, { + key, + toolCallId: message.acpToolCallId ?? null, + positionId: message.id, + call: message, + result: null, + metadata: metadataFromMessage(message), + }); + } + + for (const row of metadataRows) { + if (!row.acpToolCallId || !TOOL_EVENT_KINDS.has(row.acpEventKind ?? '')) continue; + const key = keyByToolCallId.get(row.acpToolCallId) ?? `tool:${row.acpToolCallId}`; + keyByToolCallId.set(row.acpToolCallId, key); + const existing = tools.get(key); + if (existing) { + mergeToolMetadata(existing, row); + existing.positionId = Math.min(existing.positionId, row.id); + } else { + tools.set(key, { + key, + toolCallId: row.acpToolCallId, + positionId: row.id, + call: row, + result: null, + metadata: metadataFromMessage(row), + }); + } + } + + assignToolResults(visibleMessages, tools, keyByToolCallId); + return tools; +} + +function assignToolResults( + visibleMessages: SessionMessage[], + tools: Map, + keyByToolCallId: Map +) { + let latestUnmatchedToolKey: string | null = null; + + for (const message of visibleMessages) { + if (message.role === 'tool_call') { + const key = toolKeyForMessage(message); + if (!tools.get(key)?.result) latestUnmatchedToolKey = key; + continue; + } + + if (message.role !== 'tool_result') { + if (message.role === 'user') latestUnmatchedToolKey = null; + continue; + } + + const keyed = message.acpToolCallId + ? tools.get(keyByToolCallId.get(message.acpToolCallId) ?? '') + : null; + const fallback = latestUnmatchedToolKey ? tools.get(latestUnmatchedToolKey) : null; + const target = keyed ?? fallback; + if (target) { + target.result = message; + latestUnmatchedToolKey = null; + } else { + tools.set(`result:${message.id}`, { + key: `result:${message.id}`, + toolCallId: message.acpToolCallId ?? null, + positionId: message.id, + call: message, + result: message, + metadata: metadataFromMessage(message), + }); + } + } +} + +function metadataFromMessage(message: SessionMessage): ToolAssembly['metadata'] { + return { + status: normalizeToolStatus(message.acpToolStatus), + toolKind: message.acpToolKind ?? null, + rawInput: message.acpRawInput, + rawOutput: message.acpRawOutput, + content: message.acpContent, + locations: message.acpLocations, + }; +} + +function mergeToolMetadata(tool: ToolAssembly, row: SessionMessage) { + const next = metadataFromMessage(row); + if (next.status) tool.metadata.status = next.status; + if (next.toolKind) tool.metadata.toolKind = next.toolKind; + if (next.rawInput !== undefined && next.rawInput !== null) tool.metadata.rawInput = next.rawInput; + if (next.rawOutput !== undefined && next.rawOutput !== null) + tool.metadata.rawOutput = next.rawOutput; + if (next.content !== undefined && next.content !== null) tool.metadata.content = next.content; + if (next.locations !== undefined && next.locations !== null) + tool.metadata.locations = next.locations; +} + +function buildTimelineEntries( + visibleMessages: SessionMessage[], + metadataRows: SessionMessage[], + tools: Map, + assignedResultIds: Set +): TimelineEntry[] { + const entries: TimelineEntry[] = []; + const visibleIds = new Set(visibleMessages.map((message) => message.id)); + const visibleToolKeys = new Set( + visibleMessages + .filter((message) => message.role === 'tool_call') + .map((message) => toolKeyForMessage(message)) + ); + + for (const message of visibleMessages) { + if (message.role === 'tool_result' && assignedResultIds.has(message.id)) continue; + entries.push({ id: message.id, type: 'visible', message }); + } + + for (const tool of tools.values()) { + if (visibleToolKeys.has(tool.key) || tool.key.startsWith('result:')) continue; + entries.push({ id: tool.positionId, type: 'hidden-tool', toolKey: tool.key }); + } + + for (const event of standaloneEvents(metadataRows)) { + if (visibleIds.has(event.id) && !isHiddenMetadata(event.message)) continue; + entries.push({ id: event.id, type: 'acp-event', event }); + } + + return entries.sort((a, b) => a.id - b.id); +} + +function standaloneEvents(metadataRows: SessionMessage[]): AcpTranscriptEvent[] { + const events: AcpTranscriptEvent[] = []; + for (const row of metadataRows) { + const kind = row.acpEventKind as AcpTranscriptEventKind | undefined; + if (!kind || !VISIBLE_STANDALONE_EVENT_KINDS.has(kind)) continue; + events.push({ + id: row.id, + kind, + title: eventTitle(kind), + content: eventContent(row), + message: row, + }); + } + return events; +} + +function eventContent(row: SessionMessage): unknown { + switch (row.acpEventKind) { + case 'usage_update': + case 'prompt_response': + return row.acpUsage ?? row.acpContent; + case 'config_options_update': + return row.acpConfigOptions ?? row.acpContent; + case 'session_mode_state': + return row.acpSessionModeState ?? row.acpContent; + case 'session_info_update': + return row.acpSessionInfo ?? row.acpContent; + default: + return row.acpContent; + } +} + +function eventTitle(kind: AcpTranscriptEventKind): string { + switch (kind) { + case 'plan_update': + return 'Plan'; + case 'usage_update': + case 'prompt_response': + return 'Usage'; + case 'config_options_update': + return 'Configuration'; + case 'session_mode_state': + case 'current_mode_update': + return 'Mode'; + case 'available_commands_update': + return 'Slash commands'; + case 'session_info_update': + return 'Session info'; + } +} + +function richToolItem(tool: ToolAssembly, displayRoots?: DisplayRootInput): RichToolItem { + const status = tool.metadata.status ?? (tool.result ? 'completed' : 'pending'); + const pending = status === 'pending' || status === 'in_progress'; + const display = formatToolDisplay(tool.call.content, displayRoots, pending); + return { + key: tool.key, + call: tool.call, + result: tool.result, + verb: display.verb, + detail: display.detail, + status, + statusLabel: toolStatusLabel(status), + statusTone: toolStatusTone(status), + toolCallId: tool.toolCallId, + toolKind: tool.metadata.toolKind ?? null, + rawInput: tool.metadata.rawInput, + rawOutput: tool.metadata.rawOutput, + content: tool.metadata.content, + locations: tool.metadata.locations, + }; +} + +function normalizeToolStatus(status: string | undefined): ToolStatus | undefined { + if (!status) return undefined; + if (status === 'pending') return 'pending'; + if (status === 'in_progress') return 'in_progress'; + if (status === 'completed' || status === 'succeeded' || status === 'success') return 'completed'; + if (status === 'failed' || status === 'error') return 'failed'; + if (status === 'cancelled' || status === 'canceled') return 'cancelled'; + return undefined; +} + +function toolStatusLabel(status: ToolStatus): string { + switch (status) { + case 'pending': + return 'Pending'; + case 'in_progress': + return 'In progress'; + case 'completed': + return 'Succeeded'; + case 'failed': + return 'Failed'; + case 'cancelled': + return 'Cancelled'; + } +} + +function toolStatusTone(status: ToolStatus): RichToolItem['statusTone'] { + switch (status) { + case 'pending': + return 'muted'; + case 'in_progress': + return 'running'; + case 'completed': + return 'success'; + case 'failed': + return 'danger'; + case 'cancelled': + return 'cancelled'; + } +} + +function pushToolGroup(groups: AcpTranscriptGroup[], item: RichToolItem) { + const last = groups[groups.length - 1]; + if (last?.type === 'tools') { + last.items.push(item); + } else { + groups.push({ type: 'tools', items: [item] }); + } +} + +function pushNonToolGroup( + groups: AcpTranscriptGroup[], + group: Exclude +) { + groups.push(group); +} + +function objectProp(value: unknown, key: string): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const prop = (value as Record)[key]; + return prop && typeof prop === 'object' && !Array.isArray(prop) + ? (prop as Record) + : null; +} + +function arrayProp(value: unknown, key: string): unknown[] | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const prop = (value as Record)[key]; + return Array.isArray(prop) ? prop : null; +} + +function stringProp(value: unknown, key: string): string | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const prop = (value as Record)[key]; + return typeof prop === 'string' ? prop : null; +} + +export function displayLocations(locations: unknown, displayRoots?: DisplayRootInput): string[] { + if (!Array.isArray(locations)) return []; + return locations + .map((location) => { + const path = stringProp(location, 'path'); + if (!path) return null; + const line = + location && typeof location === 'object' + ? (location as Record).line + : undefined; + const suffix = typeof line === 'number' ? `:${line}` : ''; + return `${makePathsRelative(path, displayRoots)}${suffix}`; + }) + .filter((location): location is string => !!location); +} + +export function toolResultText(item: RichToolItem): string { + const structuredText = textFromAcpContent(item.content); + if (structuredText) return structuredText; + if (typeof item.rawOutput === 'string') return item.rawOutput; + if (item.rawOutput !== undefined && item.rawOutput !== null) return formatJson(item.rawOutput); + if (item.result?.content) return stripCodeFences(item.result.content); + return ''; +} diff --git a/apps/staged/src/lib/features/sessions/sessionModalHelpers.ts b/apps/staged/src/lib/features/sessions/sessionModalHelpers.ts index 9267cf5ec..e8f9a990b 100644 --- a/apps/staged/src/lib/features/sessions/sessionModalHelpers.ts +++ b/apps/staged/src/lib/features/sessions/sessionModalHelpers.ts @@ -302,7 +302,7 @@ export function groupByVerb( return groups; } -export function verbGroupSummary(group: VerbGroup): string { +export function verbGroupSummary(group: { verb: string; items: readonly unknown[] }): string { const noun = VERB_NOUNS[group.verb] || 'items'; return `${group.items.length} ${noun}`; } diff --git a/apps/staged/src/lib/types.ts b/apps/staged/src/lib/types.ts index 9c2a5ac5d..6466b9e65 100644 --- a/apps/staged/src/lib/types.ts +++ b/apps/staged/src/lib/types.ts @@ -419,6 +419,23 @@ export interface SessionMessage { createdAt: number; /** Image IDs attached to this message (user messages only). */ imageIds?: string[]; + acpEventKind?: string; + acpProtocolVersion?: string; + acpAgentCapabilities?: unknown; + acpAuthMethods?: unknown; + acpAgentInfo?: unknown; + acpMessageId?: string; + acpToolCallId?: string; + acpToolKind?: string; + acpToolStatus?: string; + acpRawInput?: unknown; + acpRawOutput?: unknown; + acpContent?: unknown; + acpLocations?: unknown; + acpUsage?: unknown; + acpSessionInfo?: unknown; + acpConfigOptions?: unknown; + acpSessionModeState?: unknown; } // ============================================================================= diff --git a/crates/acp-client/Cargo.toml b/crates/acp-client/Cargo.toml index f6b2a4560..b2cd64f14 100644 --- a/crates/acp-client/Cargo.toml +++ b/crates/acp-client/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" description = "Full-featured client for ACP (Agent Client Protocol) agents like Goose and Claude Code" [dependencies] -agent-client-protocol = { version = "0.10", features = ["unstable"] } +agent-client-protocol = { version = "0.15.1", features = ["unstable"] } tokio = { version = "1", features = ["process", "io-util", "time", "sync", "rt", "macros"] } tokio-util = { version = "0.7", features = ["compat"] } serde = { version = "1", features = ["derive"] } diff --git a/crates/acp-client/src/driver.rs b/crates/acp-client/src/driver.rs index 595b0e27b..269c70436 100644 --- a/crates/acp-client/src/driver.rs +++ b/crates/acp-client/src/driver.rs @@ -12,16 +12,25 @@ use std::ffi::OsString; use std::io::Read; use std::path::{Path, PathBuf}; use std::process::Stdio; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; use agent_client_protocol::{ - Agent, ClientSideConnection, ContentBlock as AcpContentBlock, ImageContent, Implementation, - InitializeRequest, LoadSessionRequest, McpCapabilities, McpServer, NewSessionRequest, - PermissionOptionId, PromptRequest, ProtocolVersion, RequestPermissionOutcome, - RequestPermissionRequest, RequestPermissionResponse, SelectedPermissionOutcome, - SessionConfigOption, SessionInfoUpdate, SessionModelState, SessionNotification, SessionUpdate, - TextContent, + schema::{ + v1::{ + AgentCapabilities, AuthMethod, AuthenticateRequest, CancelNotification, + ContentBlock as AcpContentBlock, ContentChunk, ImageContent, Implementation, + InitializeRequest, InitializeResponse, LoadSessionRequest, McpCapabilities, McpServer, + NewSessionRequest, PermissionOption as SchemaPermissionOption, PermissionOptionId, + PermissionOptionKind as SchemaPermissionOptionKind, PromptRequest, PromptResponse, + RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, + SelectedPermissionOutcome, SessionConfigOption, SessionInfoUpdate, SessionModeState, + SessionNotification, SessionUpdate, StopReason, TextContent, ToolCallContent, + }, + ProtocolVersion, + }, + Agent, ByteStreams, Client, ConnectionTo, }; use async_trait::async_trait; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -37,6 +46,8 @@ use nix::unistd::Pid; use crate::types::blox_acp_command; +static PERMISSION_REQUEST_COUNTER: AtomicU64 = AtomicU64::new(1); + // ============================================================================= // Public traits and types // ============================================================================= @@ -73,7 +84,7 @@ pub trait MessageWriter: Send + Sync { ); /// Record the result/output of a tool call. - async fn record_tool_result(&self, content: &str); + async fn record_tool_result(&self, tool_call_id: &str, content: &str); /// Called when session info is updated (title, timestamps, etc.). /// @@ -81,15 +92,228 @@ pub trait MessageWriter: Send + Sync { /// session, or extracted from setup responses. async fn on_session_info_update(&self, _info: &SessionInfoUpdate) {} - /// Called when model state is received from session setup responses. + /// Called when mode state is received from session setup responses. /// - /// `SessionModelState` is only delivered in `NewSessionResponse` and - /// `LoadSessionResponse`. Mid-session model changes are surfaced through + /// `SessionModeState` is delivered in `NewSessionResponse` and + /// `LoadSessionResponse`. Mid-session mode/model changes are surfaced through /// `on_config_option_update` via `ConfigOptionUpdate` with category `Model`. - async fn on_model_state_update(&self, _state: &SessionModelState) {} + async fn on_model_state_update(&self, _state: &SessionModeState) {} /// Called when session configuration options change. async fn on_config_option_update(&self, _options: &[SessionConfigOption]) {} + + /// Called after ACP initialization has negotiated agent capabilities. + async fn on_initialize(&self, _metadata: &AcpInitializeMetadata) {} + + /// Attach rich ACP tool-call metadata to an existing tool-call row. + async fn record_tool_call_metadata(&self, _metadata: AcpToolCallMetadata) {} + + /// Persist an ACP event that does not map cleanly to a visible transcript row. + async fn record_acp_event_metadata(&self, _metadata: AcpEventMetadata) {} + + /// Ask the client UI to resolve an ACP permission request. + /// + /// Implementations that cannot prompt should automatically approve the + /// request unless the prompt turn has been cancelled. + async fn request_permission( + &self, + request: AcpPermissionRequest, + cancel_token: CancellationToken, + ) -> AcpPermissionDecision { + if cancel_token.is_cancelled() { + AcpPermissionDecision::Cancelled + } else { + autoapprove_permission_decision(&request) + } + } +} + +fn permission_option_is_approval(option: &AcpPermissionOption) -> bool { + match option.kind.approval_status() { + Some(is_approval) => is_approval, + None => legacy_permission_option_is_approval(option), + } +} + +fn legacy_permission_option_is_approval(option: &AcpPermissionOption) -> bool { + let option_id = option.option_id.to_ascii_lowercase(); + let name = option.name.to_ascii_lowercase(); + + option_id.starts_with("allow") + || option_id.starts_with("approve") + || name.contains("allow") + || name.contains("approve") +} + +pub fn autoapprove_permission_decision(request: &AcpPermissionRequest) -> AcpPermissionDecision { + request + .options + .iter() + .find(|option| permission_option_is_approval(option)) + .or_else(|| request.options.first()) + .map(|option| AcpPermissionDecision::Selected { + option_id: option.option_id.clone(), + }) + .unwrap_or(AcpPermissionDecision::Selected { + option_id: "approve".to_string(), + }) +} + +#[derive(Debug, Clone, Default)] +pub struct AcpInitializeMetadata { + pub protocol_version: String, + pub agent_capabilities: Option, + pub auth_methods: Option, + pub agent_info: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct AcpToolCallMetadata { + pub event_kind: Option, + pub message_id: Option, + pub tool_call_id: Option, + pub tool_kind: Option, + pub tool_status: Option, + pub raw_input: Option, + pub raw_output: Option, + pub content: Option, + pub locations: Option, +} + +impl AcpToolCallMetadata { + fn has_update_fields(&self) -> bool { + self.tool_kind.is_some() + || self.tool_status.is_some() + || self.raw_input.is_some() + || self.raw_output.is_some() + || self.content.is_some() + || self.locations.is_some() + } +} + +#[derive(Debug, Clone, Default)] +pub struct AcpEventMetadata { + pub event_kind: Option, + pub message_id: Option, + pub content: Option, + pub usage: Option, +} + +#[derive(Debug, Clone)] +pub struct AcpPermissionRequest { + pub request_id: String, + pub session_id: String, + pub tool_call_id: String, + pub tool_title: Option, + pub tool_kind: Option, + pub tool_status: Option, + pub raw_input: Option, + pub raw_output: Option, + pub content: Option, + pub locations: Option, + pub options: Vec, + pub raw_request: Option, +} + +#[derive(Debug, Clone)] +pub struct AcpPermissionOption { + pub option_id: String, + pub name: String, + pub kind: AcpPermissionOptionKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AcpPermissionOptionKind { + AllowOnce, + AllowAlways, + RejectOnce, + RejectAlways, + Unknown, +} + +impl AcpPermissionOptionKind { + fn approval_status(self) -> Option { + match self { + Self::AllowOnce | Self::AllowAlways => Some(true), + Self::RejectOnce | Self::RejectAlways => Some(false), + Self::Unknown => None, + } + } +} + +impl From for AcpPermissionOptionKind { + fn from(kind: SchemaPermissionOptionKind) -> Self { + match kind { + SchemaPermissionOptionKind::AllowOnce => Self::AllowOnce, + SchemaPermissionOptionKind::AllowAlways => Self::AllowAlways, + SchemaPermissionOptionKind::RejectOnce => Self::RejectOnce, + SchemaPermissionOptionKind::RejectAlways => Self::RejectAlways, + _ => Self::Unknown, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AcpPermissionDecision { + Selected { option_id: String }, + Cancelled, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReplayBoundary { + pub role: String, + pub content: String, + pub acp_message_id: Option, + pub acp_tool_call_id: Option, +} + +impl ReplayBoundary { + pub fn legacy(role: String, content: String) -> Self { + Self { + role, + content, + acp_message_id: None, + acp_tool_call_id: None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentRunOutcome { + Completed, + Cancelled, +} + +impl AgentRunOutcome { + fn from_stop_reason(stop_reason: StopReason) -> Self { + match stop_reason { + StopReason::Cancelled => Self::Cancelled, + StopReason::EndTurn + | StopReason::MaxTokens + | StopReason::MaxTurnRequests + | StopReason::Refusal => Self::Completed, + _ => Self::Completed, + } + } +} + +fn serialize_as_string(value: &T) -> Option { + match serde_json::to_value(value).ok()? { + serde_json::Value::String(s) => Some(s), + other => Some(other.to_string()), + } +} + +fn serialize_non_empty(items: &[T]) -> Option { + if items.is_empty() { + None + } else { + serde_json::to_value(items).ok() + } +} + +fn serialize_value(value: &T) -> Option { + serde_json::to_value(value).ok() } /// Storage interface for persisting agent session data. @@ -101,15 +325,30 @@ pub trait Store: Send + Sync { /// Save the agent's session ID for resumption. fn set_agent_session_id(&self, session_id: &str, agent_session_id: &str) -> Result<(), String>; - /// Retrieve existing session messages as `(role, content)` pairs. + /// Retrieve existing visible session messages as `(role, content)` pairs. /// - /// Used during session resumption to match replayed notifications - /// against previously persisted messages. The default implementation - /// returns an empty list, which is correct for stores that do not - /// support message persistence (e.g. `NoOpStore`). + /// This is the legacy replay fallback for stores that do not expose ACP + /// message IDs via [`Store::get_session_replay_boundaries`]. fn get_session_messages(&self, _session_id: &str) -> Result, String> { Ok(vec![]) } + + /// Retrieve replay boundaries for session resumption. + /// + /// Implementations should prefer ACP message IDs and tool-call IDs from + /// persisted metadata when available, while still including visible + /// transcript rows as a fallback. + fn get_session_replay_boundaries( + &self, + session_id: &str, + ) -> Result, String> { + self.get_session_messages(session_id).map(|messages| { + messages + .into_iter() + .map(|(role, content)| ReplayBoundary::legacy(role, content)) + .collect() + }) + } } /// Everything needed to run one turn of an agent. @@ -134,7 +373,7 @@ pub trait AgentDriver { writer: &Arc, cancel_token: &CancellationToken, agent_session_id: Option<&str>, - ) -> Result<(), String>; + ) -> Result; } // ============================================================================= @@ -227,6 +466,20 @@ impl AcpDriver { }) } + pub(crate) fn from_agent(agent: &crate::types::AcpAgent) -> Self { + Self { + binary_path: agent.binary_path.clone(), + acp_args: agent.acp_args.clone(), + agent_label: agent.label.clone(), + is_remote: false, + extra_env: Vec::new(), + env_snapshot: None, + interpreter_env_snapshot: None, + mcp_servers: Vec::new(), + remote_working_dir: None, + } + } + /// Create a driver that proxies through `sq blox acp `. pub fn for_workspace(workspace_name: &str, agent_id: Option<&str>) -> Result { let binary_path = blox_cli::find_sq_binary().ok_or_else(|| { @@ -503,6 +756,43 @@ fn resolve_spawn_working_dir(working_dir: &Path, is_remote: bool) -> PathBuf { working_dir.to_path_buf() } +fn absolute_local_path(path: &Path) -> Result { + if path.is_absolute() { + return Ok(path.to_path_buf()); + } + + std::env::current_dir() + .map(|cwd| cwd.join(path)) + .map_err(|e| { + format!( + "Failed to resolve absolute ACP cwd for {}: {e}", + path.display() + ) + }) +} + +fn resolve_acp_working_dir( + working_dir: &Path, + is_remote: bool, + remote_working_dir: Option<&Path>, +) -> Result { + if is_remote { + let remote_dir = remote_working_dir.ok_or_else(|| { + "Remote ACP sessions require an absolute remote working directory; could not resolve the workspace repo path" + .to_string() + })?; + if !remote_dir.is_absolute() { + return Err(format!( + "Remote ACP working directory must be absolute, got {}", + remote_dir.display() + )); + } + return Ok(remote_dir.to_path_buf()); + } + + absolute_local_path(working_dir) +} + #[async_trait(?Send)] impl AgentDriver for AcpDriver { async fn run( @@ -515,8 +805,13 @@ impl AgentDriver for AcpDriver { writer: &Arc, cancel_token: &CancellationToken, agent_session_id: Option<&str>, - ) -> Result<(), String> { + ) -> Result { let spawn_working_dir = resolve_spawn_working_dir(working_dir, self.is_remote); + let acp_working_dir = resolve_acp_working_dir( + working_dir, + self.is_remote, + self.remote_working_dir.as_deref(), + )?; if self.is_remote && spawn_working_dir.as_path() != working_dir { log::warn!( "Remote ACP spawn cwd missing ({}); falling back to {}", @@ -692,7 +987,7 @@ impl AgentDriver for AcpDriver { .ok_or_else(|| "Failed to get stdout".to_string())?; let stdin_compat = stdin.compat_write(); - let incoming_reader: Box = if self.is_remote { + let incoming_reader: Box = if self.is_remote { let (normalized_stdout_writer, normalized_stdout_reader) = tokio::io::duplex(64 * 1024); tokio::task::spawn_local(async move { if let Err(error) = @@ -721,57 +1016,74 @@ impl AgentDriver for AcpDriver { let stdout_compat = incoming_reader.compat(); let is_resuming = agent_session_id.is_some(); - let db_messages = if is_resuming { - store.get_session_messages(session_id).unwrap_or_else(|e| { - log::warn!("Failed to load session messages for replay matching: {e}"); - vec![] - }) + let replay_boundaries = if is_resuming { + store + .get_session_replay_boundaries(session_id) + .unwrap_or_else(|e| { + log::warn!("Failed to load session replay boundaries: {e}"); + vec![] + }) } else { vec![] }; let handler = Arc::new(AcpNotificationHandler::new( Arc::clone(writer), is_resuming, - db_messages, + replay_boundaries, + cancel_token.clone(), )); - let handler_for_conn = Arc::clone(&handler); - - let (connection, io_future) = - ClientSideConnection::new(handler_for_conn, stdin_compat, stdout_compat, |fut| { - tokio::task::spawn_local(fut); - }); - - tokio::task::spawn_local(async move { - if let Err(e) = io_future.await { - log::error!("ACP IO error: {e:?}"); - } - }); - - let acp_working_dir = if let Some(ref remote_dir) = self.remote_working_dir { - remote_dir.clone() - } else if self.is_remote { - PathBuf::from(".") - } else { - working_dir.to_path_buf() - }; - - let protocol_result = tokio::select! { - _ = cancel_token.cancelled() => { - log::info!("Session {session_id} cancelled"); - writer.finalize().await; - graceful_stop(&mut child, self.is_remote).await; - return Ok(()); - } - result = run_acp_protocol( - &connection, &acp_working_dir, prompt, images, store, - session_id, agent_session_id, &handler, &self.mcp_servers, - ) => result, - }; + let transport = ByteStreams::new(stdin_compat, stdout_compat); + let permission_handler = Arc::clone(&handler); + let notification_handler = Arc::clone(&handler); + let protocol_result = Client + .builder() + .name("staged-acp-client") + .on_receive_request( + async move |args: RequestPermissionRequest, responder, _connection| { + let response = permission_handler.request_permission(args).await?; + responder.respond(response) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_notification( + async move |notification: SessionNotification, _connection| { + notification_handler + .session_notification(notification) + .await + }, + agent_client_protocol::on_receive_notification!(), + ) + .connect_with(transport, async |connection| { + run_acp_protocol( + &connection, + &acp_working_dir, + prompt, + images, + store, + session_id, + agent_session_id, + &handler, + &self.mcp_servers, + &self.agent_label, + cancel_token, + ) + .await + .map_err(agent_client_protocol::util::internal_error) + }) + .await + .map_err(|e| format!("ACP protocol failed: {e:?}")); writer.finalize().await; graceful_stop(&mut child, self.is_remote).await; - protocol_result + match protocol_result { + Ok(outcome) => Ok(outcome), + Err(e) if cancel_token.is_cancelled() => { + log::info!("Session {session_id} cancelled during ACP teardown: {e}"); + Ok(AgentRunOutcome::Cancelled) + } + Err(e) => Err(e), + } } } @@ -1012,9 +1324,9 @@ enum HandlerPhase { /// Accumulates replay notifications and matches them against DB messages. struct ReplayBuffer { - /// `(role, content)` pairs from the DB, in order. - db_messages: Vec<(String, String)>, - /// Index into `db_messages` of the next message to match. + /// Persisted replay boundaries from the DB, in order. + db_messages: Vec, + /// Index into `db_messages` of the next boundary to match. match_cursor: usize, /// Index of the last non-user message in `db_messages`. /// When the cursor passes this, replay is considered complete. @@ -1023,6 +1335,9 @@ struct ReplayBuffer { current_text: String, /// Role of the current streaming message (`"user"` or `"assistant"`). current_role: Option, + /// ACP message ID for the current streaming message, when the provider + /// includes one in message chunks. + current_message_id: Option, /// Tool-call IDs observed during replay (used as a safety-net later). replayed_tool_call_ids: HashSet, /// Timestamp of the last notification received during replay. @@ -1032,13 +1347,13 @@ struct ReplayBuffer { } impl ReplayBuffer { - fn new(db_messages: Vec<(String, String)>) -> Self { + fn new(db_messages: Vec) -> Self { // Find index of last non-user message. let target_index = db_messages .iter() .enumerate() .rev() - .find(|(_, (role, _))| role != "user") + .find(|(_, message)| message.role != "user") .map(|(i, _)| i); Self { @@ -1047,6 +1362,7 @@ impl ReplayBuffer { target_index, current_text: String::new(), current_role: None, + current_message_id: None, replayed_tool_call_ids: HashSet::new(), last_notification_at: Instant::now(), received_any: false, @@ -1059,30 +1375,69 @@ impl ReplayBuffer { fn finalize_current(&mut self) -> bool { if let Some(role) = self.current_role.take() { if !self.current_text.is_empty() { - self.current_text.clear(); - return self.try_match(&role); + let event = ReplayEvent { + role, + content: std::mem::take(&mut self.current_text), + acp_message_id: self.current_message_id.take(), + acp_tool_call_id: None, + }; + return self.try_match(&event); } } + self.current_message_id = None; false } - /// Try to match a role against `db_messages[match_cursor]`. + /// Try to match a replay event against persisted replay boundaries. /// Returns `true` if replay is now considered complete. - fn try_match(&mut self, role: &str) -> bool { + fn try_match(&mut self, event: &ReplayEvent) -> bool { if self.match_cursor >= self.db_messages.len() { return self.is_complete(); } - let (db_role, _) = &self.db_messages[self.match_cursor]; + if let Some(idx) = self.find_id_match(event) { + self.match_cursor = idx + 1; + return self.is_complete(); + } - if role == db_role { + let boundary = &self.db_messages[self.match_cursor]; + if boundary.matches_fallback(event) { self.match_cursor += 1; } - // Don't advance cursor on role mismatch. self.is_complete() } + fn push_text_chunk(&mut self, role: &str, message_id: Option<&str>, text: &str) -> bool { + let role_changed = self.current_role.as_deref() != Some(role); + let message_id_changed = !role_changed + && matches!( + (self.current_message_id.as_deref(), message_id), + (Some(current), Some(next)) if current != next + ); + + let mut done = false; + if role_changed || message_id_changed { + done = self.finalize_current(); + self.current_role = Some(role.to_string()); + self.current_message_id = message_id.map(str::to_string); + } else if self.current_message_id.is_none() { + self.current_message_id = message_id.map(str::to_string); + } + + self.current_text.push_str(text); + done + } + + fn find_id_match(&self, event: &ReplayEvent) -> Option { + self.db_messages + .iter() + .enumerate() + .skip(self.match_cursor) + .find(|(_, boundary)| boundary.matches_id(event)) + .map(|(idx, _)| idx) + } + /// Returns `true` if the match cursor has passed the target index. fn is_complete(&self) -> bool { match self.target_index { @@ -1092,18 +1447,59 @@ impl ReplayBuffer { } } +struct ReplayEvent { + role: String, + content: String, + acp_message_id: Option, + acp_tool_call_id: Option, +} + +impl ReplayBoundary { + fn matches_id(&self, event: &ReplayEvent) -> bool { + match ( + self.acp_message_id.as_deref(), + event.acp_message_id.as_deref(), + ) { + (Some(boundary_id), Some(event_id)) if boundary_id == event_id => return true, + _ => {} + } + + matches!( + ( + self.acp_tool_call_id.as_deref(), + event.acp_tool_call_id.as_deref() + ), + (Some(boundary_id), Some(event_id)) if boundary_id == event_id + ) + } + + fn matches_fallback(&self, event: &ReplayEvent) -> bool { + if self.role != event.role { + return false; + } + + if self.content.is_empty() || event.content.is_empty() { + return true; + } + + self.content == event.content + } +} + struct AcpNotificationHandler { writer: Arc, phase: Mutex, /// Signalled when replay matching determines all DB messages have been replayed. replay_done: tokio::sync::Notify, + permission_cancel_token: CancellationToken, } impl AcpNotificationHandler { fn new( writer: Arc, replaying: bool, - db_messages: Vec<(String, String)>, + db_messages: Vec, + permission_cancel_token: CancellationToken, ) -> Self { let phase = if replaying { HandlerPhase::Replaying(ReplayBuffer::new(db_messages)) @@ -1117,18 +1513,22 @@ impl AcpNotificationHandler { writer, phase: Mutex::new(phase), replay_done: tokio::sync::Notify::new(), + permission_cancel_token, } } - /// Check whether the replay phase has been idle for at least `timeout`. - /// Returns `false` if not in the Replaying phase or no notification received yet. - async fn is_replay_idle(&self, timeout: Duration) -> bool { - let phase = self.phase.lock().await; - if let HandlerPhase::Replaying(buf) = &*phase { - buf.received_any && buf.last_notification_at.elapsed() >= timeout - } else { - false + async fn finalize_replay_if_idle(&self, timeout: Duration) -> bool { + let mut phase = self.phase.lock().await; + if let HandlerPhase::Replaying(buf) = &mut *phase { + if buf.received_any && buf.last_notification_at.elapsed() >= timeout { + let completed = buf.finalize_current(); + if completed { + self.replay_done.notify_one(); + } + return true; + } } + false } /// Transition from Replaying to WaitingForPrompt. @@ -1136,7 +1536,13 @@ impl AcpNotificationHandler { async fn transition_to_waiting_for_prompt(&self) { let mut phase = self.phase.lock().await; let ids = match &mut *phase { - HandlerPhase::Replaying(buf) => std::mem::take(&mut buf.replayed_tool_call_ids), + HandlerPhase::Replaying(buf) => { + let completed = buf.finalize_current(); + if completed { + self.replay_done.notify_one(); + } + std::mem::take(&mut buf.replayed_tool_call_ids) + } HandlerPhase::WaitingForPrompt { .. } | HandlerPhase::Live { .. } => return, }; *phase = HandlerPhase::WaitingForPrompt { @@ -1158,30 +1564,44 @@ impl AcpNotificationHandler { replayed_tool_call_ids: ids, }; } + + fn cancel_pending_permissions(&self) { + self.permission_cancel_token.cancel(); + } + + async fn is_replay_complete(&self) -> bool { + let phase = self.phase.lock().await; + match &*phase { + HandlerPhase::Replaying(buf) => buf.is_complete(), + HandlerPhase::WaitingForPrompt { .. } | HandlerPhase::Live { .. } => true, + } + } } -#[async_trait(?Send)] -impl agent_client_protocol::Client for AcpNotificationHandler { +impl AcpNotificationHandler { async fn request_permission( &self, args: RequestPermissionRequest, ) -> agent_client_protocol::Result { - let option_id = args - .options - .first() - .map(|opt| opt.option_id.clone()) - .unwrap_or_else(|| PermissionOptionId::new("approve")); + if self.permission_cancel_token.is_cancelled() { + return Ok(RequestPermissionResponse::new( + RequestPermissionOutcome::Cancelled, + )); + } - Ok(RequestPermissionResponse::new( - RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(option_id)), - )) + let request = acp_permission_request_from_args(&args); + let decision = self + .writer + .request_permission(request, self.permission_cancel_token.clone()) + .await; + Ok(permission_response_for_decision(decision)) } async fn session_notification( &self, notification: SessionNotification, ) -> agent_client_protocol::Result<()> { - // Session metadata events are forwarded regardless of phase. + // Session state updates are forwarded regardless of phase. match ¬ification.update { SessionUpdate::SessionInfoUpdate(info) => { self.writer.on_session_info_update(info).await; @@ -1193,23 +1613,49 @@ impl agent_client_protocol::Client for AcpNotificationHandler { .await; return Ok(()); } + SessionUpdate::CurrentModeUpdate(update) => { + self.writer + .record_acp_event_metadata(AcpEventMetadata { + event_kind: Some("current_mode_update".to_string()), + content: serialize_value(update), + ..Default::default() + }) + .await; + return Ok(()); + } + SessionUpdate::AvailableCommandsUpdate(update) => { + self.writer + .record_acp_event_metadata(AcpEventMetadata { + event_kind: Some("available_commands_update".to_string()), + content: serialize_value(update), + ..Default::default() + }) + .await; + return Ok(()); + } _ => {} } // Determine the action to take under the lock, then drop the lock // before calling into the writer to avoid holding it across await points. enum LiveAction { - AppendText(String), + AppendText { + text: String, + metadata: AcpEventMetadata, + }, + RecordAcpEvent(AcpEventMetadata), RecordToolCall { id: String, title: String, raw_input: Option, + metadata: AcpToolCallMetadata, }, ToolCallUpdate { id: String, title: Option, raw_input: Option, result: Option, + metadata: AcpToolCallMetadata, }, Ignore, Drop, @@ -1232,40 +1678,50 @@ impl agent_client_protocol::Client for AcpNotificationHandler { let completed = match ¬ification.update { SessionUpdate::AgentMessageChunk(chunk) => { if let AcpContentBlock::Text(text) = &chunk.content { - // If switching from non-assistant role, finalize previous. - let mut done = false; - if buf.current_role.as_deref() != Some("assistant") { - done = buf.finalize_current(); - buf.current_role = Some("assistant".to_string()); - } - buf.current_text.push_str(&text.text); - done + buf.push_text_chunk( + "assistant", + chunk.message_id.as_ref().map(|id| id.0.as_ref()), + &text.text, + ) } else { false } } SessionUpdate::UserMessageChunk(chunk) => { if let AcpContentBlock::Text(text) = &chunk.content { - let mut done = false; - if buf.current_role.as_deref() != Some("user") { - done = buf.finalize_current(); - buf.current_role = Some("user".to_string()); - } - buf.current_text.push_str(&text.text); - done + buf.push_text_chunk( + "user", + chunk.message_id.as_ref().map(|id| id.0.as_ref()), + &text.text, + ) } else { false } } - SessionUpdate::ToolCall(_tc) => { + SessionUpdate::ToolCall(tc) => { buf.finalize_current(); - buf.try_match("tool_call") + buf.try_match(&ReplayEvent { + role: "tool_call".to_string(), + content: tc.title.clone(), + acp_message_id: None, + acp_tool_call_id: Some(tc.tool_call_id.0.to_string()), + }) } SessionUpdate::ToolCallUpdate(update) if update.fields.content.is_some() => { buf.finalize_current(); - buf.try_match("tool_result") + buf.try_match(&ReplayEvent { + role: "tool_result".to_string(), + content: update + .fields + .content + .as_ref() + .and_then(|content| extract_content_preview(content)) + .unwrap_or_default(), + acp_message_id: None, + acp_tool_call_id: Some(update.tool_call_id.0.to_string()), + }) } SessionUpdate::AgentThoughtChunk(_) => { // Thinking is not persisted — ignore. @@ -1303,37 +1759,98 @@ impl agent_client_protocol::Client for AcpNotificationHandler { match ¬ification.update { SessionUpdate::AgentMessageChunk(chunk) => { + let metadata = message_chunk_metadata("agent_message_chunk", chunk); if let AcpContentBlock::Text(text) = &chunk.content { - LiveAction::AppendText(text.text.clone()) + LiveAction::AppendText { + text: text.text.clone(), + metadata, + } } else { - LiveAction::Drop + LiveAction::RecordAcpEvent(metadata) + } + } + SessionUpdate::UserMessageChunk(chunk) => LiveAction::RecordAcpEvent( + message_chunk_metadata("user_message_chunk", chunk), + ), + SessionUpdate::ToolCall(tool_call) => { + let id = tool_call.tool_call_id.0.to_string(); + LiveAction::RecordToolCall { + id: id.clone(), + title: tool_call.title.clone(), + raw_input: tool_call.raw_input.clone(), + metadata: AcpToolCallMetadata { + event_kind: Some("tool_call".to_string()), + tool_call_id: Some(id), + tool_kind: serialize_as_string(&tool_call.kind), + tool_status: serialize_as_string(&tool_call.status), + raw_input: tool_call.raw_input.clone(), + raw_output: tool_call.raw_output.clone(), + content: serialize_non_empty(&tool_call.content), + locations: serialize_non_empty(&tool_call.locations), + ..Default::default() + }, } } - SessionUpdate::ToolCall(tool_call) => LiveAction::RecordToolCall { - id: tool_call.tool_call_id.0.to_string(), - title: tool_call.title.clone(), - raw_input: tool_call.raw_input.clone(), - }, SessionUpdate::ToolCallUpdate(update) => { let tc_id = update.tool_call_id.0.to_string(); let title = update.fields.title.clone(); let raw_input = update.fields.raw_input.clone(); + let metadata = AcpToolCallMetadata { + event_kind: Some("tool_call_update".to_string()), + tool_call_id: Some(tc_id.clone()), + tool_kind: update + .fields + .kind + .as_ref() + .and_then(serialize_as_string), + tool_status: update + .fields + .status + .as_ref() + .and_then(serialize_as_string), + raw_input: raw_input.clone(), + raw_output: update.fields.raw_output.clone(), + content: update + .fields + .content + .as_ref() + .and_then(|content| serialize_non_empty(content)), + locations: update + .fields + .locations + .as_ref() + .and_then(|locations| serialize_non_empty(locations)), + ..Default::default() + }; let result = update .fields .content .as_ref() .and_then(|c| extract_content_preview(c)); - if title.is_some() || raw_input.is_some() || result.is_some() { + if title.is_some() + || raw_input.is_some() + || result.is_some() + || metadata.has_update_fields() + { LiveAction::ToolCallUpdate { id: tc_id, title, raw_input, result, + metadata, } } else { LiveAction::Drop } } + SessionUpdate::UsageUpdate(update) => { + LiveAction::RecordAcpEvent(usage_update_metadata(update)) + } + SessionUpdate::Plan(plan) => LiveAction::RecordAcpEvent(AcpEventMetadata { + event_kind: Some("plan_update".to_string()), + content: serialize_value(plan), + ..Default::default() + }), _ => LiveAction::Ignore, } } @@ -1343,31 +1860,39 @@ impl agent_client_protocol::Client for AcpNotificationHandler { // Execute the live action without holding the phase lock. match live_action { - LiveAction::AppendText(text) => { + LiveAction::AppendText { text, metadata } => { self.writer.append_text(&text).await; + self.writer.record_acp_event_metadata(metadata).await; + } + LiveAction::RecordAcpEvent(metadata) => { + self.writer.record_acp_event_metadata(metadata).await; } LiveAction::RecordToolCall { id, title, raw_input, + metadata, } => { self.writer .record_tool_call(&id, &title, raw_input.as_ref()) .await; + self.writer.record_tool_call_metadata(metadata).await; } LiveAction::ToolCallUpdate { id, title, raw_input, result, + metadata, } => { if title.is_some() || raw_input.is_some() { self.writer .update_tool_call_title(&id, title.as_deref(), raw_input.as_ref()) .await; } + self.writer.record_tool_call_metadata(metadata).await; if let Some(preview) = result { - self.writer.record_tool_result(&preview).await; + self.writer.record_tool_result(&id, &preview).await; } } LiveAction::Ignore => { @@ -1379,6 +1904,100 @@ impl agent_client_protocol::Client for AcpNotificationHandler { } } +#[cfg(test)] +fn permission_decision_for_options( + options: &[SchemaPermissionOption], + cancelled: bool, +) -> AcpPermissionDecision { + if cancelled { + return AcpPermissionDecision::Cancelled; + } + + let request = AcpPermissionRequest { + request_id: "test-request".to_string(), + session_id: "test-session".to_string(), + tool_call_id: "test-tool-call".to_string(), + tool_title: None, + tool_kind: None, + tool_status: None, + raw_input: None, + raw_output: None, + content: None, + locations: None, + options: options + .iter() + .map(acp_permission_option_from_schema) + .collect(), + raw_request: None, + }; + + autoapprove_permission_decision(&request) +} + +#[cfg(test)] +fn permission_response_for_options( + options: &[agent_client_protocol::schema::v1::PermissionOption], + cancelled: bool, +) -> RequestPermissionResponse { + permission_response_for_decision(permission_decision_for_options(options, cancelled)) +} + +fn permission_response_for_decision(decision: AcpPermissionDecision) -> RequestPermissionResponse { + match decision { + AcpPermissionDecision::Cancelled => { + RequestPermissionResponse::new(RequestPermissionOutcome::Cancelled) + } + AcpPermissionDecision::Selected { option_id } => { + RequestPermissionResponse::new(RequestPermissionOutcome::Selected( + SelectedPermissionOutcome::new(PermissionOptionId::new(option_id)), + )) + } + } +} + +fn acp_permission_option_from_schema(option: &SchemaPermissionOption) -> AcpPermissionOption { + AcpPermissionOption { + option_id: option.option_id.0.as_ref().to_string(), + name: option.name.clone(), + kind: option.kind.into(), + } +} + +fn acp_permission_request_from_args(args: &RequestPermissionRequest) -> AcpPermissionRequest { + let tool = &args.tool_call; + let tool_call_id = tool.tool_call_id.0.as_ref().to_string(); + let request_counter = PERMISSION_REQUEST_COUNTER.fetch_add(1, Ordering::Relaxed); + AcpPermissionRequest { + request_id: format!( + "{}:{tool_call_id}:{request_counter}", + args.session_id.0.as_ref() + ), + session_id: args.session_id.0.as_ref().to_string(), + tool_call_id, + tool_title: tool.fields.title.clone(), + tool_kind: tool.fields.kind.as_ref().and_then(serialize_as_string), + tool_status: tool.fields.status.as_ref().and_then(serialize_as_string), + raw_input: tool.fields.raw_input.clone(), + raw_output: tool.fields.raw_output.clone(), + content: tool + .fields + .content + .as_ref() + .and_then(|content| serialize_non_empty(content)), + locations: tool + .fields + .locations + .as_ref() + .and_then(|locations| serialize_non_empty(locations)), + options: args + .options + .iter() + .map(acp_permission_option_from_schema) + .collect(), + raw_request: serialize_value(args), + } +} + /// Extract the tool-call ID from a session update, if it carries one. fn notification_tool_call_id(update: &SessionUpdate) -> Option { match update { @@ -1388,13 +2007,52 @@ fn notification_tool_call_id(update: &SessionUpdate) -> Option { } } +fn message_chunk_metadata(event_kind: &str, chunk: &ContentChunk) -> AcpEventMetadata { + AcpEventMetadata { + event_kind: Some(event_kind.to_string()), + message_id: chunk.message_id.as_ref().map(|id| id.0.to_string()), + content: serialize_value(chunk), + ..Default::default() + } +} + +fn usage_update_metadata(update: &T) -> AcpEventMetadata { + AcpEventMetadata { + event_kind: Some("usage_update".to_string()), + usage: serialize_value(update), + content: serialize_value(update), + ..Default::default() + } +} + +fn prompt_response_metadata(response: &PromptResponse) -> Option { + let usage = response.usage.as_ref().and_then(serialize_value); + usage.as_ref()?; + + Some(AcpEventMetadata { + event_kind: Some("prompt_response".to_string()), + message_id: None, + usage, + content: serialize_value(response), + }) +} + +fn send_session_cancel( + connection: &ConnectionTo, + acp_session_id: &str, +) -> Result<(), String> { + connection + .send_notification(CancelNotification::new(acp_session_id.to_string())) + .map_err(|e| format!("Failed to send ACP session/cancel: {e:?}")) +} + // ============================================================================= // Protocol helpers // ============================================================================= #[allow(clippy::too_many_arguments)] async fn run_acp_protocol( - connection: &ClientSideConnection, + connection: &ConnectionTo, working_dir: &Path, prompt: &str, images: &[(String, String)], @@ -1403,34 +2061,52 @@ async fn run_acp_protocol( acp_session_id: Option<&str>, handler: &Arc, mcp_servers: &[McpServer], -) -> Result<(), String> { - let agent_session_id = tokio::time::timeout( + agent_label: &str, + cancel_token: &CancellationToken, +) -> Result { + let setup_task = tokio::time::timeout( ACP_SETUP_TIMEOUT, - setup_acp_session( + setup_acp_session(AcpSessionSetupContext { connection, working_dir, store, - &handler.writer, + writer: &handler.writer, our_session_id, acp_session_id, mcp_servers, - ), - ) - .await - .map_err(|_| { - format!( - "Timed out waiting for ACP protocol startup after {}s", - ACP_SETUP_TIMEOUT.as_secs() - ) - })??; + agent_label, + }), + ); + let setup = tokio::select! { + _ = cancel_token.cancelled() => { + handler.cancel_pending_permissions(); + return Ok(AgentRunOutcome::Cancelled); + } + result = setup_task => { + result + .map_err(|_| { + format!( + "Timed out waiting for ACP protocol startup after {}s", + ACP_SETUP_TIMEOUT.as_secs() + ) + })?? + } + }; // If resuming, wait for replay to complete (content match OR idle timeout). // An absolute 10s timeout prevents a hang if the server sends zero replay // notifications (e.g. the remote session was garbage-collected). - if acp_session_id.is_some() { + if acp_session_id.is_some() && !handler.is_replay_complete().await { let absolute_deadline = tokio::time::Instant::now() + Duration::from_secs(10); loop { tokio::select! { + _ = cancel_token.cancelled() => { + handler.cancel_pending_permissions(); + if let Err(e) = send_session_cancel(connection, &setup.agent_session_id) { + log::warn!("{e}"); + } + return Ok(AgentRunOutcome::Cancelled); + } _ = handler.replay_done.notified() => { break; } @@ -1439,32 +2115,61 @@ async fn run_acp_protocol( break; } _ = tokio::time::sleep(Duration::from_millis(100)) => { - if handler.is_replay_idle(Duration::from_secs(1)).await { + if handler.finalize_replay_if_idle(Duration::from_secs(1)).await { break; } } } } + } + if acp_session_id.is_some() { handler.transition_to_waiting_for_prompt().await; } - let mut content_blocks = vec![AcpContentBlock::Text(TextContent::new(prompt))]; - for (data, mime_type) in images { - content_blocks.push(AcpContentBlock::Image(ImageContent::new( - data.as_str(), - mime_type.as_str(), - ))); + let supports_images = setup.agent_capabilities.prompt_capabilities.image; + if !images.is_empty() && !supports_images { + log::warn!( + "ACP agent for session {our_session_id} does not advertise promptCapabilities.image; omitting {} image attachment(s)", + images.len() + ); } - let prompt_request = PromptRequest::new(agent_session_id, content_blocks); + let content_blocks = build_prompt_content_blocks(prompt, images, supports_images); + let prompt_request = PromptRequest::new(setup.agent_session_id.clone(), content_blocks); handler.transition_to_live().await; - connection - .prompt(prompt_request) - .await - .map_err(|e| format!("Prompt failed: {e:?}"))?; + let prompt_task = connection.send_request(prompt_request).block_task(); + tokio::pin!(prompt_task); - Ok(()) + let prompt_response = tokio::select! { + result = &mut prompt_task => { + result.map_err(|e| format!("Prompt failed: {e:?}"))? + } + _ = cancel_token.cancelled() => { + handler.cancel_pending_permissions(); + if let Err(e) = send_session_cancel(connection, &setup.agent_session_id) { + log::warn!("{e}"); + } + + match tokio::time::timeout(Duration::from_secs(5), &mut prompt_task).await { + Ok(result) => result.map_err(|e| format!("Prompt failed after cancellation: {e:?}"))?, + Err(_) => { + log::warn!( + "Timed out waiting for ACP prompt response after session/cancel for session {our_session_id}" + ); + return Ok(AgentRunOutcome::Cancelled); + } + } + } + }; + + if let Some(metadata) = prompt_response_metadata(&prompt_response) { + handler.writer.record_acp_event_metadata(metadata).await; + } + + Ok(AgentRunOutcome::from_stop_reason( + prompt_response.stop_reason, + )) } /// Whether the agent advertises support for the transport an MCP server needs. @@ -1481,30 +2186,63 @@ fn mcp_server_transport_supported(server: &McpServer, caps: &McpCapabilities) -> } } -#[allow(clippy::too_many_arguments)] -async fn setup_acp_session( - connection: &ClientSideConnection, - working_dir: &Path, - store: &Arc, - writer: &Arc, - our_session_id: &str, - acp_session_id: Option<&str>, - mcp_servers: &[McpServer], -) -> Result { +struct AcpSessionSetup { + agent_session_id: String, + agent_capabilities: AgentCapabilities, +} + +struct AcpSessionSetupContext<'a> { + connection: &'a ConnectionTo, + working_dir: &'a Path, + store: &'a Arc, + writer: &'a Arc, + our_session_id: &'a str, + acp_session_id: Option<&'a str>, + mcp_servers: &'a [McpServer], + agent_label: &'a str, +} + +async fn setup_acp_session(context: AcpSessionSetupContext<'_>) -> Result { + let AcpSessionSetupContext { + connection, + working_dir, + store, + writer, + our_session_id, + acp_session_id, + mcp_servers, + agent_label, + } = context; + let client_info = Implementation::new("acp-client", env!("CARGO_PKG_VERSION")); - let init_request = InitializeRequest::new(ProtocolVersion::LATEST).client_info(client_info); + let init_request = InitializeRequest::new(ProtocolVersion::V1).client_info(client_info); let init_response = connection - .initialize(init_request) + .send_request(init_request) + .block_task() .await .map_err(|e| format!("ACP init failed: {e:?}"))?; - let mcp_caps = &init_response.agent_capabilities.mcp_capabilities; + log_initialize_response(agent_label, &init_response); + writer + .on_initialize(&initialize_metadata(&init_response)) + .await; + + if init_response.protocol_version != ProtocolVersion::V1 { + return Err(format!( + "Agent negotiated unsupported ACP protocol version {} (expected {})", + init_response.protocol_version, + ProtocolVersion::V1 + )); + } + + authenticate_if_advertised(connection, &init_response.auth_methods).await?; // Required servers must all have a supported transport, or the session // fails. Route the decision through mcp_server_transport_supported so the - // transport->capability mapping lives in exactly one place — a newly added + // transport->capability mapping lives in exactly one place: a newly added // transport stays validated here instead of silently slipping through. + let mcp_caps = &init_response.agent_capabilities.mcp_capabilities; if mcp_servers .iter() .any(|server| !mcp_server_transport_supported(server, mcp_caps)) @@ -1522,9 +2260,11 @@ async fn setup_acp_session( )); } + let agent_capabilities = init_response.agent_capabilities.clone(); + match acp_session_id { Some(existing_id) => { - if !init_response.agent_capabilities.load_session { + if !agent_capabilities.load_session { return Err( "Agent does not support load_session — cannot resume conversation".to_string(), ); @@ -1535,27 +2275,32 @@ async fn setup_acp_session( ); let load_response = connection - .load_session( + .send_request( LoadSessionRequest::new(existing_id.to_string(), working_dir.to_path_buf()) .mcp_servers(mcp_servers.to_vec()), ) + .block_task() .await .map_err(|e| format!("Failed to load ACP session: {e:?}"))?; - if let Some(ref models) = load_response.models { - writer.on_model_state_update(models).await; + if let Some(ref modes) = load_response.modes { + writer.on_model_state_update(modes).await; } if let Some(ref options) = load_response.config_options { writer.on_config_option_update(options).await; } - Ok(existing_id.to_string()) + Ok(AcpSessionSetup { + agent_session_id: existing_id.to_string(), + agent_capabilities, + }) } None => { let new_session_request = NewSessionRequest::new(working_dir.to_path_buf()).mcp_servers(mcp_servers.to_vec()); let session_response = connection - .new_session(new_session_request) + .send_request(new_session_request) + .block_task() .await .map_err(|e| format!("Failed to create ACP session: {e:?}"))?; @@ -1564,18 +2309,117 @@ async fn setup_acp_session( .set_agent_session_id(our_session_id, &new_id) .map_err(|e| format!("Failed to save agent session ID: {e}"))?; - if let Some(ref models) = session_response.models { - writer.on_model_state_update(models).await; + if let Some(ref modes) = session_response.modes { + writer.on_model_state_update(modes).await; } if let Some(ref options) = session_response.config_options { writer.on_config_option_update(options).await; } - Ok(new_id) + Ok(AcpSessionSetup { + agent_session_id: new_id, + agent_capabilities, + }) } } } +fn initialize_metadata(init_response: &InitializeResponse) -> AcpInitializeMetadata { + AcpInitializeMetadata { + protocol_version: init_response.protocol_version.to_string(), + agent_capabilities: serde_json::to_value(&init_response.agent_capabilities).ok(), + auth_methods: serde_json::to_value(&init_response.auth_methods).ok(), + agent_info: init_response + .agent_info + .as_ref() + .and_then(|info| serde_json::to_value(info).ok()), + } +} + +fn log_initialize_response(agent_label: &str, init_response: &InitializeResponse) { + let agent_name = init_response + .agent_info + .as_ref() + .map(|info| info.name.as_str()) + .unwrap_or(agent_label); + let agent_version = init_response + .agent_info + .as_ref() + .map(|info| info.version.as_str()) + .unwrap_or("unknown"); + let capabilities = serde_json::to_string(&init_response.agent_capabilities) + .unwrap_or_else(|_| "".to_string()); + let auth_methods = describe_auth_methods(&init_response.auth_methods); + + log::debug!( + "ACP initialized provider={agent_label} agent={agent_name} version={agent_version} protocol={} capabilities={} auth_methods=[{}]", + init_response.protocol_version, + capabilities, + auth_methods + ); +} + +fn describe_auth_methods(auth_methods: &[AuthMethod]) -> String { + auth_methods + .iter() + .map(|method| format!("{} ({})", method.name(), method.id())) + .collect::>() + .join(", ") +} + +async fn authenticate_if_advertised( + connection: &ConnectionTo, + auth_methods: &[AuthMethod], +) -> Result<(), String> { + let Some(method) = auth_methods.first() else { + return Ok(()); + }; + + log::debug!( + "ACP agent advertised authentication methods; selecting {} ({})", + method.name(), + method.id() + ); + + connection + .send_request(AuthenticateRequest::new(method.id().clone())) + .block_task() + .await + .map_err(|e| { + format!( + "ACP authentication failed with method {} ({}): {e:?}", + method.name(), + method.id() + ) + })?; + + Ok(()) +} + +fn build_prompt_content_blocks( + prompt: &str, + images: &[(String, String)], + supports_images: bool, +) -> Vec { + let mut content_blocks = vec![AcpContentBlock::Text(TextContent::new(prompt))]; + + if supports_images { + for (data, mime_type) in images { + content_blocks.push(AcpContentBlock::Image(ImageContent::new( + data.as_str(), + mime_type.as_str(), + ))); + } + } else if !images.is_empty() { + content_blocks.push(AcpContentBlock::Text(TextContent::new(format!( + "[{} image attachment(s) omitted because this ACP provider does not advertise promptCapabilities.image]", + images.len() + )))); + } + + content_blocks +} + /// Strip outer markdown code fences from tool-result content. /// Agents often wrap results in ``` fences which are redundant in our `
` display.
 /// The closing fence may be absent when content was truncated by the preview limit.
@@ -1594,10 +2438,10 @@ pub fn strip_code_fences(content: &str) -> String {
     content.to_string()
 }
 
-fn extract_content_preview(content: &[agent_client_protocol::ToolCallContent]) -> Option {
+fn extract_content_preview(content: &[ToolCallContent]) -> Option {
     for item in content {
         match item {
-            agent_client_protocol::ToolCallContent::Content(c) => {
+            ToolCallContent::Content(c) => {
                 if let AcpContentBlock::Text(text) = &c.content {
                     let preview: String = text.text.chars().take(500).collect();
                     return Some(if text.text.len() > 500 {
@@ -1607,7 +2451,7 @@ fn extract_content_preview(content: &[agent_client_protocol::ToolCallContent]) -
                     });
                 }
             }
-            agent_client_protocol::ToolCallContent::Diff(d) => {
+            ToolCallContent::Diff(d) => {
                 return Some(format!(
                     "{}{}",
                     d.path.display(),
@@ -1618,7 +2462,7 @@ fn extract_content_preview(content: &[agent_client_protocol::ToolCallContent]) -
                     }
                 ));
             }
-            agent_client_protocol::ToolCallContent::Terminal(t) => {
+            ToolCallContent::Terminal(t) => {
                 return Some(format!("Terminal: {}", t.terminal_id.0));
             }
             _ => {}
@@ -1687,7 +2531,7 @@ impl MessageWriter for BasicMessageWriter {
         // Nothing to do for basic implementation
     }
 
-    async fn record_tool_result(&self, content: &str) {
+    async fn record_tool_result(&self, _tool_call_id: &str, content: &str) {
         let mut current = self.text.lock().await;
         current.push_str(&format!("\n[Result: {}]\n", content));
     }
@@ -1696,16 +2540,26 @@ impl MessageWriter for BasicMessageWriter {
 #[cfg(test)]
 mod tests {
     use super::{
-        acp_spawn_command, consume_remote_acp_line, decode_remote_acp_line,
-        env_shebang_interpreter, guarded_path_for_agent_binary, is_broad_toolchain_dir,
-        mcp_server_transport_supported, path_with_inserted_agent_bin_dir, remote_acp_segments,
-        resolve_spawn_working_dir, sanitize_remote_acp_chunk, shell_exec_line, shell_quote,
-        AcpDriver, McpCapabilities, McpServer, RemoteLineOutcome,
+        acp_spawn_command, autoapprove_permission_decision, build_prompt_content_blocks,
+        consume_remote_acp_line, decode_remote_acp_line, env_shebang_interpreter,
+        guarded_path_for_agent_binary, is_broad_toolchain_dir, mcp_server_transport_supported,
+        path_with_inserted_agent_bin_dir, permission_response_for_options, remote_acp_segments,
+        resolve_acp_working_dir, resolve_spawn_working_dir, sanitize_remote_acp_chunk,
+        shell_exec_line, shell_quote, AcpDriver, AcpEventMetadata, AcpNotificationHandler,
+        AcpPermissionOption, AcpPermissionOptionKind, AcpPermissionRequest, AgentRunOutcome,
+        BasicMessageWriter, MessageWriter, RemoteLineOutcome, ReplayBoundary, ReplayBuffer,
+        ReplayEvent,
+    };
+    use agent_client_protocol::schema::v1::{
+        ContentBlock as AcpContentBlock, McpCapabilities, McpServer, McpServerHttp, McpServerSse,
+        McpServerStdio, PermissionOption, PermissionOptionKind, Plan, PlanEntry, PlanEntryPriority,
+        PlanEntryStatus, RequestPermissionOutcome, SessionNotification, SessionUpdate, StopReason,
     };
-    use agent_client_protocol::{McpServerHttp, McpServerSse, McpServerStdio};
     use std::ffi::OsString;
     use std::path::{Path, PathBuf};
+    use std::sync::{Arc, Mutex};
     use std::time::{SystemTime, UNIX_EPOCH};
+    use tokio_util::sync::CancellationToken;
 
     fn unique_test_dir(prefix: &str) -> PathBuf {
         let nonce = SystemTime::now()
@@ -1739,6 +2593,77 @@ mod tests {
             .expect("path entries should be utf8")
     }
 
+    fn acp_permission_request(options: Vec) -> AcpPermissionRequest {
+        AcpPermissionRequest {
+            request_id: "test-request".to_string(),
+            session_id: "test-session".to_string(),
+            tool_call_id: "test-tool-call".to_string(),
+            tool_title: None,
+            tool_kind: None,
+            tool_status: None,
+            raw_input: None,
+            raw_output: None,
+            content: None,
+            locations: None,
+            options,
+            raw_request: None,
+        }
+    }
+
+    fn acp_permission_option(
+        option_id: &str,
+        name: &str,
+        kind: AcpPermissionOptionKind,
+    ) -> AcpPermissionOption {
+        AcpPermissionOption {
+            option_id: option_id.to_string(),
+            name: name.to_string(),
+            kind,
+        }
+    }
+
+    #[derive(Default)]
+    struct RecordingMessageWriter {
+        events: Mutex>,
+    }
+
+    #[async_trait::async_trait]
+    impl MessageWriter for RecordingMessageWriter {
+        async fn append_text(&self, _text: &str) {}
+
+        async fn finalize(&self) {}
+
+        async fn record_tool_call(
+            &self,
+            _tool_call_id: &str,
+            _title: &str,
+            _raw_input: Option<&serde_json::Value>,
+        ) {
+        }
+
+        async fn update_tool_call_title(
+            &self,
+            _tool_call_id: &str,
+            _title: Option<&str>,
+            _raw_input: Option<&serde_json::Value>,
+        ) {
+        }
+
+        async fn record_tool_result(&self, _tool_call_id: &str, _content: &str) {}
+
+        async fn record_acp_event_metadata(&self, metadata: AcpEventMetadata) {
+            self.events.lock().unwrap().push(metadata);
+        }
+    }
+
+    fn test_plan() -> Plan {
+        Plan::new(vec![PlanEntry::new(
+            "Inspect current state",
+            PlanEntryPriority::Medium,
+            PlanEntryStatus::Pending,
+        )])
+    }
+
     #[test]
     fn consumes_wrapped_json_line_across_multiple_chunks() {
         let mut pending = String::new();
@@ -1916,6 +2841,60 @@ mod tests {
         );
     }
 
+    #[test]
+    fn remote_acp_working_dir_requires_remote_path() {
+        let error = resolve_acp_working_dir(Path::new("/tmp/local"), true, None)
+            .expect_err("remote ACP cwd should be required");
+        assert!(error.contains("absolute remote working directory"));
+    }
+
+    #[test]
+    fn remote_acp_working_dir_rejects_relative_path() {
+        let error = resolve_acp_working_dir(Path::new("/tmp/local"), true, Some(Path::new("repo")))
+            .expect_err("relative remote ACP cwd should be rejected");
+        assert!(error.contains("must be absolute"));
+    }
+
+    #[test]
+    fn remote_acp_working_dir_uses_absolute_remote_path() {
+        let remote = Path::new("/home/bloxer/repo");
+        assert_eq!(
+            resolve_acp_working_dir(Path::new("/tmp/local"), true, Some(remote)).unwrap(),
+            remote
+        );
+    }
+
+    #[test]
+    fn local_acp_working_dir_is_absolute() {
+        let resolved = resolve_acp_working_dir(Path::new("."), false, None).unwrap();
+        assert!(resolved.is_absolute());
+    }
+
+    #[test]
+    fn image_prompt_blocks_are_omitted_when_unsupported() {
+        let images = vec![("abcd".to_string(), "image/png".to_string())];
+        let blocks = build_prompt_content_blocks("inspect this", &images, false);
+
+        assert_eq!(blocks.len(), 2);
+        assert!(matches!(blocks[0], AcpContentBlock::Text(_)));
+        match &blocks[1] {
+            AcpContentBlock::Text(text) => {
+                assert!(text.text.contains("image attachment(s) omitted"));
+            }
+            other => panic!("expected omission notice text block, got {other:?}"),
+        }
+    }
+
+    #[test]
+    fn image_prompt_blocks_are_sent_when_supported() {
+        let images = vec![("abcd".to_string(), "image/png".to_string())];
+        let blocks = build_prompt_content_blocks("inspect this", &images, true);
+
+        assert_eq!(blocks.len(), 2);
+        assert!(matches!(blocks[0], AcpContentBlock::Text(_)));
+        assert!(matches!(blocks[1], AcpContentBlock::Image(_)));
+    }
+
     #[test]
     fn shell_quote_simple_value() {
         assert_eq!(
@@ -2190,4 +3169,242 @@ mod tests {
 
         assert!(output.is_empty());
     }
+
+    #[test]
+    fn replay_boundary_prefers_acp_message_id_over_content() {
+        let mut buffer = ReplayBuffer::new(vec![ReplayBoundary {
+            role: "assistant".to_string(),
+            content: "persisted text".to_string(),
+            acp_message_id: Some("msg-1".to_string()),
+            acp_tool_call_id: None,
+        }]);
+
+        let completed = buffer.try_match(&ReplayEvent {
+            role: "assistant".to_string(),
+            content: "provider replayed different text".to_string(),
+            acp_message_id: Some("msg-1".to_string()),
+            acp_tool_call_id: None,
+        });
+
+        assert!(completed);
+        assert_eq!(buffer.match_cursor, 1);
+    }
+
+    #[test]
+    fn replay_buffer_splits_assistant_chunks_when_message_id_changes() {
+        let mut buffer = ReplayBuffer::new(vec![
+            ReplayBoundary {
+                role: "assistant".to_string(),
+                content: "first".to_string(),
+                acp_message_id: Some("msg-1".to_string()),
+                acp_tool_call_id: None,
+            },
+            ReplayBoundary {
+                role: "assistant".to_string(),
+                content: "second".to_string(),
+                acp_message_id: Some("msg-2".to_string()),
+                acp_tool_call_id: None,
+            },
+        ]);
+
+        let completed = buffer.push_text_chunk("assistant", Some("msg-1"), "first");
+
+        assert!(!completed);
+        assert_eq!(buffer.match_cursor, 0);
+
+        let completed = buffer.push_text_chunk("assistant", Some("msg-2"), "second");
+
+        assert!(!completed);
+        assert_eq!(buffer.match_cursor, 1);
+        assert_eq!(buffer.current_message_id.as_deref(), Some("msg-2"));
+        assert_eq!(buffer.current_text, "second");
+
+        let completed = buffer.finalize_current();
+
+        assert!(completed);
+        assert_eq!(buffer.match_cursor, 2);
+    }
+
+    #[tokio::test]
+    async fn replay_handler_treats_user_only_boundaries_as_complete() {
+        let writer: Arc = Arc::new(BasicMessageWriter::new());
+        let handler = AcpNotificationHandler::new(
+            writer,
+            true,
+            vec![ReplayBoundary::legacy(
+                "user".to_string(),
+                "previous prompt".to_string(),
+            )],
+            CancellationToken::new(),
+        );
+
+        assert!(handler.is_replay_complete().await);
+    }
+
+    #[tokio::test]
+    async fn replay_handler_drops_replayed_plan_updates() {
+        let writer = Arc::new(RecordingMessageWriter::default());
+        let handler = AcpNotificationHandler::new(
+            writer.clone(),
+            true,
+            vec![ReplayBoundary::legacy(
+                "assistant".to_string(),
+                "previous response".to_string(),
+            )],
+            CancellationToken::new(),
+        );
+
+        handler
+            .session_notification(SessionNotification::new(
+                "session-1",
+                SessionUpdate::Plan(test_plan()),
+            ))
+            .await
+            .expect("replayed plan should be accepted");
+
+        assert!(
+            writer.events.lock().unwrap().is_empty(),
+            "plan updates replayed by session/load must not be persisted again"
+        );
+
+        handler.transition_to_live().await;
+        handler
+            .session_notification(SessionNotification::new(
+                "session-1",
+                SessionUpdate::Plan(test_plan()),
+            ))
+            .await
+            .expect("live plan should be accepted");
+
+        let events = writer.events.lock().unwrap();
+        assert_eq!(events.len(), 1);
+        assert_eq!(events[0].event_kind.as_deref(), Some("plan_update"));
+    }
+
+    #[test]
+    fn replay_boundary_falls_back_to_role_and_content_without_ids() {
+        let mut buffer = ReplayBuffer::new(vec![ReplayBoundary::legacy(
+            "assistant".to_string(),
+            "persisted text".to_string(),
+        )]);
+
+        let mismatch = buffer.try_match(&ReplayEvent {
+            role: "assistant".to_string(),
+            content: "different text".to_string(),
+            acp_message_id: None,
+            acp_tool_call_id: None,
+        });
+        assert!(!mismatch);
+        assert_eq!(buffer.match_cursor, 0);
+
+        let completed = buffer.try_match(&ReplayEvent {
+            role: "assistant".to_string(),
+            content: "persisted text".to_string(),
+            acp_message_id: None,
+            acp_tool_call_id: None,
+        });
+        assert!(completed);
+        assert_eq!(buffer.match_cursor, 1);
+    }
+
+    #[test]
+    fn cancelled_permission_response_uses_acp_cancelled_outcome() {
+        let options = vec![PermissionOption::new(
+            "approve",
+            "Approve",
+            PermissionOptionKind::AllowOnce,
+        )];
+
+        let response = permission_response_for_options(&options, true);
+
+        assert!(matches!(
+            response.outcome,
+            RequestPermissionOutcome::Cancelled
+        ));
+    }
+
+    #[test]
+    fn permission_response_autoapproves_allow_option_before_cancellation() {
+        let options = vec![
+            PermissionOption::new("reject", "Reject", PermissionOptionKind::RejectOnce),
+            PermissionOption::new("approve", "Approve", PermissionOptionKind::AllowOnce),
+        ];
+
+        let response = permission_response_for_options(&options, false);
+
+        match response.outcome {
+            RequestPermissionOutcome::Selected(outcome) => {
+                assert_eq!(outcome.option_id.0.as_ref(), "approve");
+            }
+            RequestPermissionOutcome::Cancelled => panic!("permission should be selected"),
+            _ => panic!("unexpected permission outcome"),
+        }
+    }
+
+    #[test]
+    fn autoapproval_ignores_reject_kind_named_dont_allow() {
+        let request = acp_permission_request(vec![
+            acp_permission_option("reject", "Don't allow", AcpPermissionOptionKind::RejectOnce),
+            acp_permission_option("approve", "Approve", AcpPermissionOptionKind::AllowOnce),
+        ]);
+
+        let decision = autoapprove_permission_decision(&request);
+
+        assert_eq!(
+            decision,
+            super::AcpPermissionDecision::Selected {
+                option_id: "approve".to_string()
+            }
+        );
+    }
+
+    #[test]
+    fn autoapproval_ignores_reject_kind_named_disallow() {
+        let request = acp_permission_request(vec![
+            acp_permission_option("reject", "Disallow", AcpPermissionOptionKind::RejectAlways),
+            acp_permission_option("allow", "Allow", AcpPermissionOptionKind::AllowAlways),
+        ]);
+
+        let decision = autoapprove_permission_decision(&request);
+
+        assert_eq!(
+            decision,
+            super::AcpPermissionDecision::Selected {
+                option_id: "allow".to_string()
+            }
+        );
+    }
+
+    #[test]
+    fn autoapproval_falls_back_to_legacy_matching_for_unknown_kind() {
+        let request = acp_permission_request(vec![
+            acp_permission_option("reject", "Reject", AcpPermissionOptionKind::RejectOnce),
+            acp_permission_option(
+                "approve-custom",
+                "Proceed",
+                AcpPermissionOptionKind::Unknown,
+            ),
+        ]);
+
+        let decision = autoapprove_permission_decision(&request);
+
+        assert_eq!(
+            decision,
+            super::AcpPermissionDecision::Selected {
+                option_id: "approve-custom".to_string()
+            }
+        );
+    }
+
+    #[test]
+    fn stop_reason_cancelled_maps_to_cancelled_outcome() {
+        assert_eq!(
+            AgentRunOutcome::from_stop_reason(StopReason::Cancelled),
+            AgentRunOutcome::Cancelled
+        );
+        assert_eq!(
+            AgentRunOutcome::from_stop_reason(StopReason::EndTurn),
+            AgentRunOutcome::Completed
+        );
+    }
 }
diff --git a/crates/acp-client/src/lib.rs b/crates/acp-client/src/lib.rs
index 1bbba9ddf..2e86528a2 100644
--- a/crates/acp-client/src/lib.rs
+++ b/crates/acp-client/src/lib.rs
@@ -22,12 +22,15 @@ mod simple;
 mod types;
 
 // Re-export the main API
-pub use agent_client_protocol::{
-    ConfigOptionUpdate, McpServer, McpServerHttp, McpServerSse, ModelInfo, SessionConfigOption,
-    SessionConfigOptionCategory, SessionInfoUpdate, SessionModelState,
+pub use agent_client_protocol::schema::v1::{
+    ConfigOptionUpdate, McpServer, McpServerHttp, McpServerSse, SessionConfigOption,
+    SessionConfigOptionCategory, SessionInfoUpdate, SessionModeState as SessionModelState,
 };
 pub use driver::{
-    strip_code_fences, AcpDriver, AgentDriver, BasicMessageWriter, MessageWriter, Store,
+    autoapprove_permission_decision, strip_code_fences, AcpDriver, AcpEventMetadata,
+    AcpInitializeMetadata, AcpPermissionDecision, AcpPermissionOption, AcpPermissionOptionKind,
+    AcpPermissionRequest, AcpToolCallMetadata, AgentDriver, AgentRunOutcome, BasicMessageWriter,
+    MessageWriter, ReplayBoundary, Store,
 };
 pub use simple::{run_acp_prompt, run_acp_prompt_with_interpreter_env_snapshot};
 pub use types::{
diff --git a/crates/acp-client/src/simple.rs b/crates/acp-client/src/simple.rs
index b2603615b..2e809d18f 100644
--- a/crates/acp-client/src/simple.rs
+++ b/crates/acp-client/src/simple.rs
@@ -10,7 +10,7 @@ use anyhow::{Context, Result};
 use async_trait::async_trait;
 use tokio_util::sync::CancellationToken;
 
-use crate::driver::{acp_spawn_command, AgentDriver, BasicMessageWriter, MessageWriter};
+use crate::driver::{AcpDriver, AgentDriver, AgentRunOutcome, BasicMessageWriter, MessageWriter};
 use crate::types::AcpAgent;
 
 /// Minimal store implementation for simple prompting (no persistence).
@@ -28,39 +28,21 @@ impl crate::driver::Store for NoOpStore {
     }
 }
 
-/// Internal driver wrapper for simple prompting.
-///
-/// This wraps the binary path and args from AcpAgent into an AgentDriver
-/// implementation compatible with the driver module's interface.
 struct SimpleDriverWrapper {
-    binary_path: std::path::PathBuf,
-    acp_args: Vec,
-    agent_label: String,
-    interpreter_env_snapshot: Option>,
+    driver: AcpDriver,
 }
 
 impl SimpleDriverWrapper {
     fn from_agent(agent: &AcpAgent) -> Self {
         Self {
-            binary_path: agent.binary_path.clone(),
-            acp_args: agent.acp_args.clone(),
-            agent_label: agent.label.clone(),
-            interpreter_env_snapshot: None,
+            driver: AcpDriver::from_agent(agent),
         }
     }
 
     fn with_interpreter_env_snapshot(mut self, vars: Vec<(String, String)>) -> Self {
-        self.interpreter_env_snapshot = Some(vars);
+        self.driver = self.driver.with_interpreter_env_snapshot(vars);
         self
     }
-
-    fn spawn_command(&self) -> crate::driver::AcpSpawnCommand {
-        acp_spawn_command(
-            &self.binary_path,
-            &self.acp_args,
-            self.interpreter_env_snapshot.as_deref(),
-        )
-    }
 }
 
 #[async_trait(?Send)]
@@ -75,7 +57,7 @@ impl AgentDriver for SimpleDriverWrapper {
         writer: &Arc,
         cancel_token: &CancellationToken,
         agent_session_id: Option<&str>,
-    ) -> Result<(), String> {
+    ) -> Result {
         if !images.is_empty() {
             log::debug!(
                 "SimpleDriverWrapper: discarding {} image(s) - not supported in simple mode",
@@ -83,213 +65,18 @@ impl AgentDriver for SimpleDriverWrapper {
             );
         }
 
-        // Use the same implementation as AcpDriver, but with our own binary/args
-        use agent_client_protocol::{
-            Agent, ClientSideConnection, ContentBlock as AcpContentBlock, Implementation,
-            InitializeRequest, LoadSessionRequest, NewSessionRequest, PermissionOptionId,
-            PromptRequest, ProtocolVersion, RequestPermissionOutcome, RequestPermissionRequest,
-            RequestPermissionResponse, SelectedPermissionOutcome, SessionNotification,
-            SessionUpdate, TextContent,
-        };
-        use std::process::Stdio;
-        use tokio::process::Command;
-        use tokio::sync::Mutex;
-        use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
-
-        let spawn_command = self.spawn_command();
-        let mut child = Command::new(&spawn_command.program)
-            .args(&spawn_command.args)
-            .current_dir(working_dir)
-            .stdin(Stdio::piped())
-            .stdout(Stdio::piped())
-            .stderr(Stdio::null())
-            .kill_on_drop(true)
-            .spawn()
-            .map_err(|e| format!("Failed to spawn {}: {e}", self.agent_label))?;
-
-        let stdin = child
-            .stdin
-            .take()
-            .ok_or_else(|| "Failed to get stdin".to_string())?;
-        let stdout = child
-            .stdout
-            .take()
-            .ok_or_else(|| "Failed to get stdout".to_string())?;
-
-        let stdin_compat = stdin.compat_write();
-        let stdout_compat = stdout.compat();
-
-        // Phase-based handler for simple prompting.
-        // With empty db_messages, replay completes immediately.
-        enum SimpleHandlerPhase {
-            Replaying,
-            WaitingForPrompt,
-            Live,
-        }
-
-        struct SimpleHandler {
-            writer: Arc,
-            phase: Mutex,
-        }
-
-        impl SimpleHandler {
-            async fn transition_to_waiting(&self) {
-                let mut phase = self.phase.lock().await;
-                *phase = SimpleHandlerPhase::WaitingForPrompt;
-            }
-
-            async fn transition_to_live(&self) {
-                let mut phase = self.phase.lock().await;
-                *phase = SimpleHandlerPhase::Live;
-            }
-        }
-
-        #[async_trait(?Send)]
-        impl agent_client_protocol::Client for SimpleHandler {
-            async fn request_permission(
-                &self,
-                args: RequestPermissionRequest,
-            ) -> agent_client_protocol::Result {
-                let option_id = args
-                    .options
-                    .first()
-                    .map(|opt| opt.option_id.clone())
-                    .unwrap_or_else(|| PermissionOptionId::new("approve"));
-
-                Ok(RequestPermissionResponse::new(
-                    RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(option_id)),
-                ))
-            }
-
-            async fn session_notification(
-                &self,
-                notification: SessionNotification,
-            ) -> agent_client_protocol::Result<()> {
-                let phase = self.phase.lock().await;
-
-                match &*phase {
-                    SimpleHandlerPhase::Replaying | SimpleHandlerPhase::WaitingForPrompt => {
-                        return Ok(());
-                    }
-                    SimpleHandlerPhase::Live => {
-                        // Drop the lock before calling writer
-                        drop(phase);
-                        match ¬ification.update {
-                            SessionUpdate::AgentMessageChunk(chunk) => {
-                                if let AcpContentBlock::Text(text) = &chunk.content {
-                                    self.writer.append_text(&text.text).await;
-                                }
-                            }
-                            _ => {
-                                // Ignore other updates for simple use
-                            }
-                        }
-                    }
-                }
-                Ok(())
-            }
-        }
-
-        let is_resuming = agent_session_id.is_some();
-        let handler = Arc::new(SimpleHandler {
-            writer: Arc::clone(writer),
-            phase: Mutex::new(if is_resuming {
-                SimpleHandlerPhase::Replaying
-            } else {
-                SimpleHandlerPhase::Live
-            }),
-        });
-        let handler_for_conn = Arc::clone(&handler);
-
-        let (connection, io_future) =
-            ClientSideConnection::new(handler_for_conn, stdin_compat, stdout_compat, |fut| {
-                tokio::task::spawn_local(fut);
-            });
-
-        tokio::task::spawn_local(async move {
-            if let Err(e) = io_future.await {
-                log::error!("ACP IO error: {e:?}");
-            }
-        });
-
-        // Protocol execution
-        let protocol_result = tokio::select! {
-            _ = cancel_token.cancelled() => {
-                log::info!("Session {session_id} cancelled");
-                writer.finalize().await;
-                return Ok(());
-            }
-            result = async {
-                // Initialize
-                let client_info = Implementation::new("acp-client", env!("CARGO_PKG_VERSION"));
-                let init_request = InitializeRequest::new(ProtocolVersion::LATEST)
-                    .client_info(client_info);
-
-                let init_response = connection
-                    .initialize(init_request)
-                    .await
-                    .map_err(|e| format!("ACP init failed: {e:?}"))?;
-
-                // Create or resume session
-                let agent_session_id = match agent_session_id {
-                    Some(existing_id) => {
-                        if !init_response.agent_capabilities.load_session {
-                            return Err("Agent does not support load_session".to_string());
-                        }
-                        connection
-                            .load_session(LoadSessionRequest::new(
-                                existing_id.to_string(),
-                                working_dir.to_path_buf(),
-                            ))
-                            .await
-                            .map_err(|e| format!("Failed to load session: {e:?}"))?;
-                        existing_id.to_string()
-                    }
-                    None => {
-                        let session_response = connection
-                            .new_session(NewSessionRequest::new(working_dir.to_path_buf()))
-                            .await
-                            .map_err(|e| format!("Failed to create session: {e:?}"))?;
-                        let new_id = session_response.session_id.to_string();
-                        store
-                            .set_agent_session_id(session_id, &new_id)
-                            .map_err(|e| format!("Failed to save session ID: {e}"))?;
-                        new_id
-                    }
-                };
-
-                // Transition: Replaying -> WaitingForPrompt -> Live
-                // For the simple driver, no DB messages means replay is
-                // effectively instant. The transitions still happen so that
-                // any straggler notifications arriving between load_session
-                // and the prompt send are correctly dropped.
-                if is_resuming {
-                    handler.transition_to_waiting().await;
-                }
-
-                // Build and send prompt
-                let prompt_request = PromptRequest::new(
-                    agent_session_id,
-                    vec![AcpContentBlock::Text(TextContent::new(prompt))],
-                );
-
-                if is_resuming {
-                    handler.transition_to_live().await;
-                }
-
-                connection
-                    .prompt(prompt_request)
-                    .await
-                    .map_err(|e| format!("Prompt failed: {e:?}"))?;
-
-                Ok::<_, String>(())
-            } => result,
-        };
-
-        writer.finalize().await;
-        let _ = child.kill().await;
-
-        protocol_result
+        self.driver
+            .run(
+                session_id,
+                prompt,
+                &[],
+                working_dir,
+                store,
+                writer,
+                cancel_token,
+                agent_session_id,
+            )
+            .await
     }
 }
 
@@ -378,81 +165,3 @@ async fn run_acp_prompt_with_options(
     .await
     .context("Task join error")?
 }
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    use std::path::{Path, PathBuf};
-    use std::time::{SystemTime, UNIX_EPOCH};
-
-    fn unique_test_dir(prefix: &str) -> PathBuf {
-        let nonce = SystemTime::now()
-            .duration_since(UNIX_EPOCH)
-            .expect("clock must be after epoch")
-            .as_nanos();
-        let dir = std::env::temp_dir().join(format!("{prefix}-{}-{nonce}", std::process::id()));
-        std::fs::create_dir_all(&dir).expect("create test dir");
-        dir
-    }
-
-    fn write_executable(path: &Path, content: &str) {
-        if let Some(parent) = path.parent() {
-            std::fs::create_dir_all(parent).expect("create executable parent");
-        }
-        std::fs::write(path, content).expect("write executable");
-
-        #[cfg(unix)]
-        {
-            use std::os::unix::fs::PermissionsExt;
-            let mut perms = std::fs::metadata(path).unwrap().permissions();
-            perms.set_mode(0o755);
-            std::fs::set_permissions(path, perms).expect("chmod executable");
-        }
-    }
-
-    fn join_path_entries(entries: &[PathBuf]) -> String {
-        std::env::join_paths(entries)
-            .expect("join path entries")
-            .into_string()
-            .expect("path entries should be utf8")
-    }
-
-    #[test]
-    fn simple_driver_uses_interpreter_snapshot_for_env_shebang_bridge() {
-        let dir = unique_test_dir("acp-simple-home-interpreter");
-        let home_bin = dir.join("home-bin");
-        let project_bin = dir.join("project-bin");
-        let agent_bin = dir.join("agent-bin");
-        let launcher = agent_bin.join("claude-agent-acp");
-        let home_node = home_bin.join("node");
-        write_executable(&home_node, "#!/bin/sh\n");
-        write_executable(&project_bin.join("node"), "#!/bin/sh\n");
-        write_executable(&launcher, "#!/usr/bin/env node\n");
-
-        let agent = AcpAgent {
-            binary_path: launcher.clone(),
-            acp_args: vec![String::from("--stdio")],
-            label: String::from("Claude Code"),
-        };
-        let home_snapshot = vec![(
-            String::from("PATH"),
-            join_path_entries(std::slice::from_ref(&home_bin)),
-        )];
-
-        let command = SimpleDriverWrapper::from_agent(&agent)
-            .with_interpreter_env_snapshot(home_snapshot)
-            .spawn_command();
-
-        assert_eq!(command.program, home_node);
-        assert_eq!(
-            command.args,
-            vec![
-                launcher.as_os_str().to_os_string(),
-                std::ffi::OsString::from("--stdio"),
-            ]
-        );
-
-        std::fs::remove_dir_all(dir).expect("cleanup test dir");
-    }
-}