From 6854dd853119f13cf1685ac620b5e35a823740bd Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 23 Jun 2026 15:37:39 +1000 Subject: [PATCH 01/15] feat(acp): upgrade SDK and persist ACP metadata Signed-off-by: Matt Toohey --- Cargo.lock | 404 +++++++++++++++++- apps/staged/src-tauri/Cargo.lock | 75 +++- apps/staged/src-tauri/src/agent/writer.rs | 120 +++++- apps/staged/src-tauri/src/store/messages.rs | 202 +++++++-- .../0017-session-message-acp-metadata/up.sql | 18 + apps/staged/src-tauri/src/store/models.rs | 37 ++ apps/staged/src-tauri/src/store/tests.rs | 50 +++ crates/acp-client/Cargo.toml | 2 +- crates/acp-client/src/driver.rs | 245 ++++++++--- crates/acp-client/src/lib.rs | 9 +- crates/acp-client/src/simple.rs | 323 +------------- 11 files changed, 1055 insertions(+), 430 deletions(-) create mode 100644 apps/staged/src-tauri/src/store/migrations/0017-session-message-acp-metadata/up.sql 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/src/agent/writer.rs b/apps/staged/src-tauri/src/agent/writer.rs index 7cfddb09c..85c61990f 100644 --- a/apps/staged/src-tauri/src/agent/writer.rs +++ b/apps/staged/src-tauri/src/agent/writer.rs @@ -15,9 +15,9 @@ use std::time::{Duration, Instant}; use async_trait::async_trait; use tokio::sync::Mutex; -use crate::store::{MessageRole, Store}; +use crate::store::{AcpMessageMetadata, MessageRole, Store}; -use acp_client::strip_code_fences; +use acp_client::{strip_code_fences, AcpToolCallMetadata}; /// Minimum interval between DB flushes for streaming text. Chunks accumulate /// in memory and are written at most this often, reducing mutex contention @@ -64,6 +64,21 @@ 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() + } +} + impl MessageWriter { pub fn new(session_id: String, store: Arc) -> Self { Self { @@ -262,6 +277,75 @@ impl acp_client::MessageWriter for MessageWriter { async fn record_tool_result(&self, content: &str) { self.record_tool_result(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 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}"); + } + } } #[cfg(test)] @@ -361,4 +445,36 @@ 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" + ); + } } diff --git a/apps/staged/src-tauri/src/store/messages.rs b/apps/staged/src-tauri/src/store/messages.rs index 56b0ecfe8..614da8bcb 100644 --- a/apps/staged/src-tauri/src/store/messages.rs +++ b/apps/staged/src-tauri/src/store/messages.rs @@ -1,10 +1,16 @@ //! 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_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 +18,42 @@ 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_message_id: row.get(7)?, + acp_tool_call_id: row.get(8)?, + acp_tool_kind: row.get(9)?, + acp_tool_status: row.get(10)?, + acp_raw_input: parse_json_value(row.get(11)?), + acp_raw_output: parse_json_value(row.get(12)?), + acp_content: parse_json_value(row.get(13)?), + acp_locations: parse_json_value(row.get(14)?), + acp_usage: parse_json_value(row.get(15)?), + acp_session_info: parse_json_value(row.get(16)?), + acp_config_options: parse_json_value(row.get(17)?), + acp_session_mode_state: parse_json_value(row.get(18)?), + }, + }) +} + impl Store { pub fn add_session_message( &self, @@ -58,22 +100,14 @@ 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) } @@ -87,6 +121,107 @@ 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 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_message_id = COALESCE(?2, acp_message_id), + acp_tool_call_id = COALESCE(?3, acp_tool_call_id), + acp_tool_kind = COALESCE(?4, acp_tool_kind), + acp_tool_status = COALESCE(?5, acp_tool_status), + acp_raw_input = COALESCE(?6, acp_raw_input), + acp_raw_output = COALESCE(?7, acp_raw_output), + acp_content = COALESCE(?8, acp_content), + acp_locations = COALESCE(?9, acp_locations), + acp_usage = COALESCE(?10, acp_usage), + acp_session_info = COALESCE(?11, acp_session_info), + acp_config_options = COALESCE(?12, acp_config_options), + acp_session_mode_state = COALESCE(?13, acp_session_mode_state) + WHERE id = ?14", + params![ + metadata.acp_event_kind.as_deref(), + 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 { + 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 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_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)", + params![ + session_id, + MessageRole::Assistant.as_str(), + now_timestamp(), + metadata.acp_event_kind.as_deref(), + 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()) + } + /// Count assistant messages created after a given timestamp. pub fn count_assistant_messages_after( &self, @@ -96,7 +231,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 +249,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/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/models.rs b/apps/staged/src-tauri/src/store/models.rs index 2c3ac4d3f..bbde34756 100644 --- a/apps/staged/src-tauri/src/store/models.rs +++ b/apps/staged/src-tauri/src/store/models.rs @@ -682,6 +682,43 @@ 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_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..d0ce448e5 100644 --- a/apps/staged/src-tauri/src/store/tests.rs +++ b/apps/staged/src-tauri/src/store/tests.rs @@ -761,6 +761,56 @@ 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_count_assistant_messages_after() { let store = Store::in_memory().unwrap(); 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..faad6c745 100644 --- a/crates/acp-client/src/driver.rs +++ b/crates/acp-client/src/driver.rs @@ -16,12 +16,18 @@ 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::{ + ContentBlock as AcpContentBlock, ImageContent, Implementation, InitializeRequest, + LoadSessionRequest, McpCapabilities, McpServer, NewSessionRequest, PermissionOptionId, + PromptRequest, RequestPermissionOutcome, RequestPermissionRequest, + RequestPermissionResponse, SelectedPermissionOutcome, SessionConfigOption, + SessionInfoUpdate, SessionModeState, SessionNotification, SessionUpdate, TextContent, + ToolCallContent, + }, + ProtocolVersion, + }, + Agent, ByteStreams, Client, ConnectionTo, }; use async_trait::async_trait; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -81,15 +87,57 @@ 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]) {} + + /// Attach rich ACP tool-call metadata to an existing tool-call row. + async fn record_tool_call_metadata(&self, _metadata: AcpToolCallMetadata) {} +} + +#[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() + } +} + +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() + } } /// Storage interface for persisting agent session data. @@ -227,6 +275,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(|| { @@ -692,7 +754,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) = @@ -734,19 +796,6 @@ impl AgentDriver for AcpDriver { is_resuming, db_messages, )); - 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 { @@ -755,6 +804,9 @@ impl AgentDriver for AcpDriver { working_dir.to_path_buf() }; + let transport = ByteStreams::new(stdin_compat, stdout_compat); + let permission_handler = Arc::clone(&handler); + let notification_handler = Arc::clone(&handler); let protocol_result = tokio::select! { _ = cancel_token.cancelled() => { log::info!("Session {session_id} cancelled"); @@ -762,10 +814,36 @@ impl AgentDriver for AcpDriver { 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, + 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, + ) + .await + .map_err(agent_client_protocol::util::internal_error) + }) => result.map_err(|e| format!("ACP protocol failed: {e:?}")), }; writer.finalize().await; @@ -1160,8 +1238,7 @@ impl AcpNotificationHandler { } } -#[async_trait(?Send)] -impl agent_client_protocol::Client for AcpNotificationHandler { +impl AcpNotificationHandler { async fn request_permission( &self, args: RequestPermissionRequest, @@ -1204,12 +1281,14 @@ impl agent_client_protocol::Client for AcpNotificationHandler { id: String, title: String, raw_input: Option, + metadata: AcpToolCallMetadata, }, ToolCallUpdate { id: String, title: Option, raw_input: Option, result: Option, + metadata: AcpToolCallMetadata, }, Ignore, Drop, @@ -1309,26 +1388,72 @@ impl agent_client_protocol::Client for AcpNotificationHandler { LiveAction::Drop } } - 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::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::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 @@ -1350,22 +1475,26 @@ impl agent_client_protocol::Client for AcpNotificationHandler { 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; } @@ -1394,7 +1523,7 @@ fn notification_tool_call_id(update: &SessionUpdate) -> Option { #[allow(clippy::too_many_arguments)] async fn run_acp_protocol( - connection: &ClientSideConnection, + connection: &ConnectionTo, working_dir: &Path, prompt: &str, images: &[(String, String)], @@ -1460,7 +1589,8 @@ async fn run_acp_protocol( handler.transition_to_live().await; connection - .prompt(prompt_request) + .send_request(prompt_request) + .block_task() .await .map_err(|e| format!("Prompt failed: {e:?}"))?; @@ -1483,7 +1613,7 @@ fn mcp_server_transport_supported(server: &McpServer, caps: &McpCapabilities) -> #[allow(clippy::too_many_arguments)] async fn setup_acp_session( - connection: &ClientSideConnection, + connection: &ConnectionTo, working_dir: &Path, store: &Arc, writer: &Arc, @@ -1492,18 +1622,27 @@ async fn setup_acp_session( mcp_servers: &[McpServer], ) -> Result { 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:?}"))?; + if init_response.protocol_version != ProtocolVersion::V1 { + return Err(format!( + "Agent negotiated unsupported ACP protocol version {} (expected {})", + init_response.protocol_version, + ProtocolVersion::V1 + )); + } + let mcp_caps = &init_response.agent_capabilities.mcp_capabilities; // 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. if mcp_servers .iter() @@ -1535,15 +1674,16 @@ 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; @@ -1555,7 +1695,8 @@ async fn setup_acp_session( 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,8 +1705,8 @@ 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; @@ -1594,10 +1735,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 +1748,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 +1759,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)); } _ => {} diff --git a/crates/acp-client/src/lib.rs b/crates/acp-client/src/lib.rs index 1bbba9ddf..2f6bc5055 100644 --- a/crates/acp-client/src/lib.rs +++ b/crates/acp-client/src/lib.rs @@ -22,12 +22,13 @@ 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, + strip_code_fences, AcpDriver, AcpToolCallMetadata, AgentDriver, BasicMessageWriter, + MessageWriter, 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..b54016a2d 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, 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)] @@ -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"); - } -} From 8021494474fd9f3a36fe9cc34b2c89fec9e43e78 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 23 Jun 2026 16:24:17 +1000 Subject: [PATCH 02/15] feat(acp): negotiate and persist provider capabilities Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/agent/writer.rs | 58 +++- apps/staged/src-tauri/src/lib.rs | 1 + apps/staged/src-tauri/src/session_commands.rs | 12 +- apps/staged/src-tauri/src/store/messages.rs | 111 +++++-- .../src-tauri/src/store/migration_tests.rs | 37 ++- .../up.sql | 7 + apps/staged/src-tauri/src/store/models.rs | 8 + apps/staged/src-tauri/src/store/tests.rs | 55 ++++ apps/staged/src-tauri/src/web_server.rs | 10 +- crates/acp-client/src/driver.rs | 280 ++++++++++++++++-- crates/acp-client/src/lib.rs | 4 +- 11 files changed, 515 insertions(+), 68 deletions(-) create mode 100644 apps/staged/src-tauri/src/store/migrations/0018-session-message-acp-initialization/up.sql diff --git a/apps/staged/src-tauri/src/agent/writer.rs b/apps/staged/src-tauri/src/agent/writer.rs index 85c61990f..5c0b076e2 100644 --- a/apps/staged/src-tauri/src/agent/writer.rs +++ b/apps/staged/src-tauri/src/agent/writer.rs @@ -17,7 +17,7 @@ use tokio::sync::Mutex; use crate::store::{AcpMessageMetadata, MessageRole, Store}; -use acp_client::{strip_code_fences, AcpToolCallMetadata}; +use acp_client::{strip_code_fences, AcpInitializeMetadata, AcpToolCallMetadata}; /// Minimum interval between DB flushes for streaming text. Chunks accumulate /// in memory and are written at most this often, reducing mutex contention @@ -324,6 +324,23 @@ impl acp_client::MessageWriter for MessageWriter { } } + 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); @@ -477,4 +494,43 @@ mod tests { "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"); + } } diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 02685a574..fbc555a3f 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -2313,6 +2313,7 @@ pub fn run() { session_commands::get_session, session_commands::get_session_messages, session_commands::get_session_messages_since, + 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/session_commands.rs b/apps/staged/src-tauri/src/session_commands.rs index 643ca70c3..673071d60 100644 --- a/apps/staged/src-tauri/src/session_commands.rs +++ b/apps/staged/src-tauri/src/session_commands.rs @@ -356,6 +356,16 @@ pub fn get_session_messages_since( .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 +547,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/store/messages.rs b/apps/staged/src-tauri/src/store/messages.rs index 614da8bcb..593efbed6 100644 --- a/apps/staged/src-tauri/src/store/messages.rs +++ b/apps/staged/src-tauri/src/store/messages.rs @@ -6,7 +6,8 @@ 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_message_id, acp_tool_call_id, acp_tool_kind, acp_tool_status, + 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)"; @@ -38,18 +39,22 @@ fn session_message_from_row(row: &Row<'_>) -> rusqlite::Result { image_ids: parse_image_ids(image_ids_raw), acp: AcpMessageMetadata { acp_event_kind: row.get(6)?, - acp_message_id: row.get(7)?, - acp_tool_call_id: row.get(8)?, - acp_tool_kind: row.get(9)?, - acp_tool_status: row.get(10)?, - acp_raw_input: parse_json_value(row.get(11)?), - acp_raw_output: parse_json_value(row.get(12)?), - acp_content: parse_json_value(row.get(13)?), - acp_locations: parse_json_value(row.get(14)?), - acp_usage: parse_json_value(row.get(15)?), - acp_session_info: parse_json_value(row.get(16)?), - acp_config_options: parse_json_value(row.get(17)?), - acp_session_mode_state: parse_json_value(row.get(18)?), + 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)?), }, }) } @@ -133,6 +138,9 @@ impl Store { 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()); @@ -141,21 +149,29 @@ impl Store { conn.execute( "UPDATE session_messages SET acp_event_kind = COALESCE(?1, acp_event_kind), - acp_message_id = COALESCE(?2, acp_message_id), - acp_tool_call_id = COALESCE(?3, acp_tool_call_id), - acp_tool_kind = COALESCE(?4, acp_tool_kind), - acp_tool_status = COALESCE(?5, acp_tool_status), - acp_raw_input = COALESCE(?6, acp_raw_input), - acp_raw_output = COALESCE(?7, acp_raw_output), - acp_content = COALESCE(?8, acp_content), - acp_locations = COALESCE(?9, acp_locations), - acp_usage = COALESCE(?10, acp_usage), - acp_session_info = COALESCE(?11, acp_session_info), - acp_config_options = COALESCE(?12, acp_config_options), - acp_session_mode_state = COALESCE(?13, acp_session_mode_state) - WHERE id = ?14", + 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(), @@ -186,6 +202,9 @@ impl Store { 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()); @@ -194,17 +213,23 @@ impl Store { conn.execute( "INSERT INTO session_messages ( session_id, role, content, created_at, image_ids, - acp_event_kind, 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 + 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)", + 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, MessageRole::Assistant.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(), @@ -222,6 +247,30 @@ impl Store { Ok(conn.last_insert_rowid()) } + /// 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, 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/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 bbde34756..573d1594e 100644 --- a/apps/staged/src-tauri/src/store/models.rs +++ b/apps/staged/src-tauri/src/store/models.rs @@ -696,6 +696,14 @@ 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, diff --git a/apps/staged/src-tauri/src/store/tests.rs b/apps/staged/src-tauri/src/store/tests.rs index d0ce448e5..dbe6b8f8f 100644 --- a/apps/staged/src-tauri/src/store/tests.rs +++ b/apps/staged/src-tauri/src/store/tests.rs @@ -811,6 +811,61 @@ fn test_acp_metadata_rows_are_hidden_from_legacy_session_messages() { assert_eq!(stored_info, r#"{"title":"ACP title"}"#); } +#[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..6da7e47ba 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -2790,6 +2790,14 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result { + 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 +2923,7 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result, + pub auth_methods: Option, + pub agent_info: Option, +} + #[derive(Debug, Clone, Default)] pub struct AcpToolCallMetadata { pub event_kind: Option, @@ -565,6 +577,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( @@ -579,6 +628,11 @@ impl AgentDriver for AcpDriver { agent_session_id: Option<&str>, ) -> Result<(), String> { 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 {}", @@ -796,14 +850,6 @@ impl AgentDriver for AcpDriver { is_resuming, db_messages, )); - 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 transport = ByteStreams::new(stdin_compat, stdout_compat); let permission_handler = Arc::clone(&handler); let notification_handler = Arc::clone(&handler); @@ -840,6 +886,7 @@ impl AgentDriver for AcpDriver { agent_session_id, &handler, &self.mcp_servers, + &self.agent_label, ) .await .map_err(agent_client_protocol::util::internal_error) @@ -1532,8 +1579,9 @@ async fn run_acp_protocol( acp_session_id: Option<&str>, handler: &Arc, mcp_servers: &[McpServer], + agent_label: &str, ) -> Result<(), String> { - let agent_session_id = tokio::time::timeout( + let setup = tokio::time::timeout( ACP_SETUP_TIMEOUT, setup_acp_session( connection, @@ -1543,6 +1591,7 @@ async fn run_acp_protocol( our_session_id, acp_session_id, mcp_servers, + agent_label, ), ) .await @@ -1577,14 +1626,15 @@ async fn run_acp_protocol( 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, content_blocks); handler.transition_to_live().await; @@ -1611,7 +1661,11 @@ fn mcp_server_transport_supported(server: &McpServer, caps: &McpCapabilities) -> } } -#[allow(clippy::too_many_arguments)] +struct AcpSessionSetup { + agent_session_id: String, + agent_capabilities: AgentCapabilities, +} + async fn setup_acp_session( connection: &ConnectionTo, working_dir: &Path, @@ -1620,7 +1674,8 @@ async fn setup_acp_session( our_session_id: &str, acp_session_id: Option<&str>, mcp_servers: &[McpServer], -) -> Result { + agent_label: &str, +) -> Result { let client_info = Implementation::new("acp-client", env!("CARGO_PKG_VERSION")); let init_request = InitializeRequest::new(ProtocolVersion::V1).client_info(client_info); @@ -1630,6 +1685,11 @@ async fn setup_acp_session( .await .map_err(|e| format!("ACP init failed: {e:?}"))?; + 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 {})", @@ -1638,12 +1698,13 @@ async fn setup_acp_session( )); } - let mcp_caps = &init_response.agent_capabilities.mcp_capabilities; + 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 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)) @@ -1661,9 +1722,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(), ); @@ -1689,7 +1752,10 @@ async fn setup_acp_session( 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 = @@ -1712,9 +1778,108 @@ async fn setup_acp_session( 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. @@ -1837,13 +2002,16 @@ 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, 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, + remote_acp_segments, resolve_acp_working_dir, resolve_spawn_working_dir, + sanitize_remote_acp_chunk, shell_exec_line, shell_quote, AcpDriver, RemoteLineOutcome, + }; + use agent_client_protocol::schema::v1::{ + ContentBlock as AcpContentBlock, McpCapabilities, McpServer, McpServerHttp, McpServerSse, + McpServerStdio, }; - use agent_client_protocol::{McpServerHttp, McpServerSse, McpServerStdio}; use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -2057,6 +2225,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!( diff --git a/crates/acp-client/src/lib.rs b/crates/acp-client/src/lib.rs index 2f6bc5055..03cbf6f5f 100644 --- a/crates/acp-client/src/lib.rs +++ b/crates/acp-client/src/lib.rs @@ -27,8 +27,8 @@ pub use agent_client_protocol::schema::v1::{ SessionConfigOptionCategory, SessionInfoUpdate, SessionModeState as SessionModelState, }; pub use driver::{ - strip_code_fences, AcpDriver, AcpToolCallMetadata, AgentDriver, BasicMessageWriter, - MessageWriter, Store, + strip_code_fences, AcpDriver, AcpInitializeMetadata, AcpToolCallMetadata, AgentDriver, + BasicMessageWriter, MessageWriter, Store, }; pub use simple::{run_acp_prompt, run_acp_prompt_with_interpreter_env_snapshot}; pub use types::{ From 3f1162a7a5829b07ec70cbe2e4aca81255d768e0 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 23 Jun 2026 16:47:53 +1000 Subject: [PATCH 03/15] feat(acp): persist rich event metadata Record ACP message chunk IDs, usage updates, and prompt response usage as hidden session_messages rows while preserving legacy transcript filtering. Expose a query path for ACP metadata rows through Tauri and web commands so richer rows can be retrieved deliberately without changing current UI rendering. Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/agent/writer.rs | 74 ++++++++++++++++- apps/staged/src-tauri/src/lib.rs | 1 + apps/staged/src-tauri/src/session_commands.rs | 10 +++ apps/staged/src-tauri/src/store/messages.rs | 31 ++++++- apps/staged/src-tauri/src/store/tests.rs | 52 ++++++++++++ apps/staged/src-tauri/src/web_server.rs | 8 ++ apps/staged/src/lib/commands.ts | 4 + apps/staged/src/lib/types.ts | 17 ++++ crates/acp-client/src/driver.rs | 83 +++++++++++++++++-- crates/acp-client/src/lib.rs | 4 +- 10 files changed, 273 insertions(+), 11 deletions(-) diff --git a/apps/staged/src-tauri/src/agent/writer.rs b/apps/staged/src-tauri/src/agent/writer.rs index 5c0b076e2..6855367aa 100644 --- a/apps/staged/src-tauri/src/agent/writer.rs +++ b/apps/staged/src-tauri/src/agent/writer.rs @@ -17,7 +17,7 @@ use tokio::sync::Mutex; use crate::store::{AcpMessageMetadata, MessageRole, Store}; -use acp_client::{strip_code_fences, AcpInitializeMetadata, AcpToolCallMetadata}; +use acp_client::{strip_code_fences, AcpEventMetadata, AcpInitializeMetadata, AcpToolCallMetadata}; /// Minimum interval between DB flushes for streaming text. Chunks accumulate /// in memory and are written at most this often, reducing mutex contention @@ -79,6 +79,23 @@ fn acp_tool_call_metadata(metadata: AcpToolCallMetadata) -> AcpMessageMetadata { } } +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 { @@ -363,6 +380,17 @@ impl acp_client::MessageWriter for MessageWriter { 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}"); + } + } } #[cfg(test)] @@ -533,4 +561,48 @@ mod tests { ); 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 fbc555a3f..cc113ea53 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -2313,6 +2313,7 @@ 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, diff --git a/apps/staged/src-tauri/src/session_commands.rs b/apps/staged/src-tauri/src/session_commands.rs index 673071d60..fe0e6a0d3 100644 --- a/apps/staged/src-tauri/src/session_commands.rs +++ b/apps/staged/src-tauri/src/session_commands.rs @@ -356,6 +356,16 @@ 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>>>, diff --git a/apps/staged/src-tauri/src/store/messages.rs b/apps/staged/src-tauri/src/store/messages.rs index 593efbed6..2933ccf9f 100644 --- a/apps/staged/src-tauri/src/store/messages.rs +++ b/apps/staged/src-tauri/src/store/messages.rs @@ -196,6 +196,17 @@ impl Store { &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()); @@ -223,7 +234,7 @@ impl Store { 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, - MessageRole::Assistant.as_str(), + role.as_str(), now_timestamp(), metadata.acp_event_kind.as_deref(), metadata.acp_protocol_version.as_deref(), @@ -247,6 +258,24 @@ impl Store { 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 diff --git a/apps/staged/src-tauri/src/store/tests.rs b/apps/staged/src-tauri/src/store/tests.rs index dbe6b8f8f..5caaa3aac 100644 --- a/apps/staged/src-tauri/src/store/tests.rs +++ b/apps/staged/src-tauri/src/store/tests.rs @@ -811,6 +811,58 @@ fn test_acp_metadata_rows_are_hidden_from_legacy_session_messages() { 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(); diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 6da7e47ba..5c974edc0 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -2790,6 +2790,14 @@ 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")?; diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index 30009bf22..ace96f378 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -729,6 +729,10 @@ export function getSessionMessagesSince( return invokeCommand('get_session_messages_since', { sessionId, sinceId }); } +export function getSessionAcpMetadataMessages(sessionId: string): Promise { + return invokeCommand('get_session_acp_metadata_messages', { sessionId }); +} + export function countAssistantMessagesAfter( sessionId: string, afterTimestamp: number 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/src/driver.rs b/crates/acp-client/src/driver.rs index 93cfb89bd..c1b555101 100644 --- a/crates/acp-client/src/driver.rs +++ b/crates/acp-client/src/driver.rs @@ -19,9 +19,9 @@ use agent_client_protocol::{ schema::{ v1::{ AgentCapabilities, AuthMethod, AuthenticateRequest, ContentBlock as AcpContentBlock, - ImageContent, Implementation, InitializeRequest, InitializeResponse, + ContentChunk, ImageContent, Implementation, InitializeRequest, InitializeResponse, LoadSessionRequest, McpCapabilities, McpServer, NewSessionRequest, PermissionOptionId, - PromptRequest, RequestPermissionOutcome, RequestPermissionRequest, + PromptRequest, PromptResponse, RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, SelectedPermissionOutcome, SessionConfigOption, SessionInfoUpdate, SessionModeState, SessionNotification, SessionUpdate, TextContent, ToolCallContent, @@ -103,6 +103,9 @@ pub trait MessageWriter: Send + Sync { /// 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) {} } #[derive(Debug, Clone, Default)] @@ -137,6 +140,14 @@ impl AcpToolCallMetadata { } } +#[derive(Debug, Clone, Default)] +pub struct AcpEventMetadata { + pub event_kind: Option, + pub message_id: Option, + pub content: Option, + pub usage: Option, +} + fn serialize_as_string(value: &T) -> Option { match serde_json::to_value(value).ok()? { serde_json::Value::String(s) => Some(s), @@ -152,6 +163,10 @@ fn serialize_non_empty(items: &[T]) -> Option(value: &T) -> Option { + serde_json::to_value(value).ok() +} + /// Storage interface for persisting agent session data. /// /// This trait abstracts the storage backend, allowing different implementations @@ -1323,7 +1338,11 @@ impl AcpNotificationHandler { // 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, @@ -1429,12 +1448,19 @@ impl 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 { @@ -1506,6 +1532,9 @@ impl AcpNotificationHandler { LiveAction::Drop } } + SessionUpdate::UsageUpdate(update) => { + LiveAction::RecordAcpEvent(usage_update_metadata(update)) + } _ => LiveAction::Ignore, } } @@ -1515,8 +1544,12 @@ impl 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, @@ -1564,6 +1597,38 @@ 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); + if usage.is_none() { + return None; + } + + Some(AcpEventMetadata { + event_kind: Some("prompt_response".to_string()), + message_id: None, + usage, + content: serialize_value(response), + }) +} + // ============================================================================= // Protocol helpers // ============================================================================= @@ -1638,12 +1703,16 @@ async fn run_acp_protocol( handler.transition_to_live().await; - connection + let prompt_response = connection .send_request(prompt_request) .block_task() .await .map_err(|e| format!("Prompt failed: {e:?}"))?; + if let Some(metadata) = prompt_response_metadata(&prompt_response) { + handler.writer.record_acp_event_metadata(metadata).await; + } + Ok(()) } diff --git a/crates/acp-client/src/lib.rs b/crates/acp-client/src/lib.rs index 03cbf6f5f..95078d630 100644 --- a/crates/acp-client/src/lib.rs +++ b/crates/acp-client/src/lib.rs @@ -27,8 +27,8 @@ pub use agent_client_protocol::schema::v1::{ SessionConfigOptionCategory, SessionInfoUpdate, SessionModeState as SessionModelState, }; pub use driver::{ - strip_code_fences, AcpDriver, AcpInitializeMetadata, AcpToolCallMetadata, AgentDriver, - BasicMessageWriter, MessageWriter, Store, + strip_code_fences, AcpDriver, AcpEventMetadata, AcpInitializeMetadata, AcpToolCallMetadata, + AgentDriver, BasicMessageWriter, MessageWriter, Store, }; pub use simple::{run_acp_prompt, run_acp_prompt_with_interpreter_env_snapshot}; pub use types::{ From a6b0dc00a8bb4d66c3c1a7ff3bba9494d941ae87 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 23 Jun 2026 17:05:51 +1000 Subject: [PATCH 04/15] feat(acp): implement lifecycle cancellation semantics Send ACP session/cancel before process teardown when a session id is available, answer cancelled permission requests with the ACP cancelled outcome, and map cancelled stop reasons into Staged interrupted completions. Use ACP message and tool IDs from existing session_messages metadata to bound resume replay before falling back to legacy role/content matching. Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/agent/mod.rs | 225 +++++++- apps/staged/src-tauri/src/project_mcp.rs | 4 + apps/staged/src-tauri/src/session_runner.rs | 31 +- apps/staged/src-tauri/src/store/messages.rs | 22 + crates/acp-client/src/driver.rs | 543 ++++++++++++++++---- crates/acp-client/src/lib.rs | 2 +- crates/acp-client/src/simple.rs | 4 +- 7 files changed, 705 insertions(+), 126 deletions(-) diff --git a/apps/staged/src-tauri/src/agent/mod.rs b/apps/staged/src-tauri/src/agent/mod.rs index 975c7b6d8..87b5a83f2 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,228 @@ 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 acp_message_chunk_keys = acp_message_chunk_content_keys(&messages); + let mut boundaries: Vec = Vec::new(); + let mut message_id_indexes: HashMap = HashMap::new(); + + for message in messages { + 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; + } + + let is_visible_assistant_or_user = + matches!(message.role, MessageRole::Assistant | MessageRole::User) + && !is_hidden_acp_metadata(&message); + let is_duplicate_acp_message_projection = is_visible_assistant_or_user + && acp_message_chunk_keys + .contains(&(message.role.as_str().to_string(), message.content.clone())); + if is_duplicate_acp_message_projection { + 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 +} + +fn acp_message_chunk_content_keys(messages: &[SessionMessage]) -> HashSet<(String, String)> { + let mut content_by_id: 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 entry = content_by_id + .entry(message_id) + .or_insert_with(|| (message.role.as_str().to_string(), String::new())); + entry + .1 + .push_str(&acp_chunk_text(message).unwrap_or_default()); + } + + content_by_id + .into_values() + .filter(|(_, content)| !content.is_empty()) + .collect() +} + +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, + } + } + + #[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, + "", + AcpMessageMetadata { + acp_event_kind: Some("agent_message_chunk".to_string()), + acp_message_id: Some("msg-1".to_string()), + acp_content: Some(serde_json::json!({ + "content": {"type": "text", "text": "hello "} + })), + ..Default::default() + }, + ), + message( + 3, + MessageRole::Assistant, + "", + AcpMessageMetadata { + acp_event_kind: Some("agent_message_chunk".to_string()), + acp_message_id: Some("msg-1".to_string()), + acp_content: Some(serde_json::json!({ + "content": {"type": "text", "text": "world"} + })), + ..Default::default() + }, + ), + 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_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, + "", + AcpMessageMetadata { + acp_event_kind: Some("agent_message_chunk".to_string()), + acp_message_id: Some("msg-2".to_string()), + acp_content: Some(serde_json::json!({ + "content": {"type": "text", "text": "new assistant text"} + })), + ..Default::default() + }, + ), + ]); + + 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/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_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 2933ccf9f..1a8ce8d6d 100644 --- a/apps/staged/src-tauri/src/store/messages.rs +++ b/apps/staged/src-tauri/src/store/messages.rs @@ -116,6 +116,28 @@ impl Store { 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) + } + /// Update the content of an existing message (used for streaming updates). pub fn update_message_content(&self, id: i64, content: &str) -> Result<(), StoreError> { let conn = self.conn.lock().unwrap(); diff --git a/crates/acp-client/src/driver.rs b/crates/acp-client/src/driver.rs index c1b555101..2943978e1 100644 --- a/crates/acp-client/src/driver.rs +++ b/crates/acp-client/src/driver.rs @@ -18,13 +18,13 @@ use std::time::{Duration, Instant}; use agent_client_protocol::{ schema::{ v1::{ - AgentCapabilities, AuthMethod, AuthenticateRequest, ContentBlock as AcpContentBlock, - ContentChunk, ImageContent, Implementation, InitializeRequest, InitializeResponse, - LoadSessionRequest, McpCapabilities, McpServer, NewSessionRequest, PermissionOptionId, - PromptRequest, PromptResponse, RequestPermissionOutcome, RequestPermissionRequest, - RequestPermissionResponse, SelectedPermissionOutcome, SessionConfigOption, - SessionInfoUpdate, SessionModeState, SessionNotification, SessionUpdate, TextContent, - ToolCallContent, + AgentCapabilities, AuthMethod, AuthenticateRequest, CancelNotification, + ContentBlock as AcpContentBlock, ContentChunk, ImageContent, Implementation, + InitializeRequest, InitializeResponse, LoadSessionRequest, McpCapabilities, McpServer, + NewSessionRequest, PermissionOptionId, PromptRequest, PromptResponse, + RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, + SelectedPermissionOutcome, SessionConfigOption, SessionInfoUpdate, SessionModeState, + SessionNotification, SessionUpdate, StopReason, TextContent, ToolCallContent, }, ProtocolVersion, }, @@ -148,6 +148,44 @@ pub struct AcpEventMetadata { pub usage: Option, } +#[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), @@ -176,15 +214,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. @@ -209,7 +262,7 @@ pub trait AgentDriver { writer: &Arc, cancel_token: &CancellationToken, agent_session_id: Option<&str>, - ) -> Result<(), String>; + ) -> Result; } // ============================================================================= @@ -641,7 +694,7 @@ 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, @@ -852,66 +905,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 transport = ByteStreams::new(stdin_compat, stdout_compat); let permission_handler = Arc::clone(&handler); let notification_handler = Arc::clone(&handler); - 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 = 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!(), + 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, ) - .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, - ) - .await - .map_err(agent_client_protocol::util::internal_error) - }) => result.map_err(|e| format!("ACP protocol failed: {e:?}")), - }; + .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), + } } } @@ -1152,9 +1213,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. @@ -1163,6 +1224,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. @@ -1172,13 +1236,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 { @@ -1187,6 +1251,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, @@ -1199,30 +1264,48 @@ 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 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 { @@ -1232,18 +1315,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)) @@ -1257,18 +1381,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. @@ -1276,7 +1404,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 { @@ -1298,6 +1432,10 @@ impl AcpNotificationHandler { replayed_tool_call_ids: ids, }; } + + fn cancel_pending_permissions(&self) { + self.permission_cancel_token.cancel(); + } } impl AcpNotificationHandler { @@ -1305,14 +1443,9 @@ impl AcpNotificationHandler { &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)), + Ok(permission_response_for_options( + &args.options, + self.permission_cancel_token.is_cancelled(), )) } @@ -1382,6 +1515,11 @@ impl AcpNotificationHandler { if buf.current_role.as_deref() != Some("assistant") { done = buf.finalize_current(); buf.current_role = Some("assistant".to_string()); + buf.current_message_id = + chunk.message_id.as_ref().map(|id| id.0.to_string()); + } else if buf.current_message_id.is_none() { + buf.current_message_id = + chunk.message_id.as_ref().map(|id| id.0.to_string()); } buf.current_text.push_str(&text.text); done @@ -1395,6 +1533,11 @@ impl AcpNotificationHandler { if buf.current_role.as_deref() != Some("user") { done = buf.finalize_current(); buf.current_role = Some("user".to_string()); + buf.current_message_id = + chunk.message_id.as_ref().map(|id| id.0.to_string()); + } else if buf.current_message_id.is_none() { + buf.current_message_id = + chunk.message_id.as_ref().map(|id| id.0.to_string()); } buf.current_text.push_str(&text.text); done @@ -1402,15 +1545,30 @@ impl AcpNotificationHandler { 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. @@ -1588,6 +1746,24 @@ impl AcpNotificationHandler { } } +fn permission_response_for_options( + options: &[agent_client_protocol::schema::v1::PermissionOption], + cancelled: bool, +) -> RequestPermissionResponse { + if cancelled { + return RequestPermissionResponse::new(RequestPermissionOutcome::Cancelled); + } + + let option_id = options + .first() + .map(|opt| opt.option_id.clone()) + .unwrap_or_else(|| PermissionOptionId::new("approve")); + + RequestPermissionResponse::new(RequestPermissionOutcome::Selected( + SelectedPermissionOutcome::new(option_id), + )) +} + /// Extract the tool-call ID from a session update, if it carries one. fn notification_tool_call_id(update: &SessionUpdate) -> Option { match update { @@ -1629,6 +1805,15 @@ fn prompt_response_metadata(response: &PromptResponse) -> Option, + 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 // ============================================================================= @@ -1645,8 +1830,9 @@ async fn run_acp_protocol( handler: &Arc, mcp_servers: &[McpServer], agent_label: &str, -) -> Result<(), String> { - let setup = tokio::time::timeout( + cancel_token: &CancellationToken, +) -> Result { + let setup_task = tokio::time::timeout( ACP_SETUP_TIMEOUT, setup_acp_session( connection, @@ -1658,14 +1844,22 @@ async fn run_acp_protocol( mcp_servers, agent_label, ), - ) - .await - .map_err(|_| { - format!( - "Timed out waiting for ACP protocol startup after {}s", - ACP_SETUP_TIMEOUT.as_secs() - ) - })??; + ); + 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 @@ -1674,6 +1868,13 @@ async fn run_acp_protocol( 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; } @@ -1682,7 +1883,7 @@ 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; } } @@ -1699,21 +1900,42 @@ async fn run_acp_protocol( ); } let content_blocks = build_prompt_content_blocks(prompt, images, supports_images); - let prompt_request = PromptRequest::new(setup.agent_session_id, content_blocks); + let prompt_request = PromptRequest::new(setup.agent_session_id.clone(), content_blocks); handler.transition_to_live().await; - let prompt_response = connection - .send_request(prompt_request) - .block_task() - .await - .map_err(|e| format!("Prompt failed: {e:?}"))?; + let prompt_task = connection.send_request(prompt_request).block_task(); + tokio::pin!(prompt_task); + + 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(()) + Ok(AgentRunOutcome::from_stop_reason( + prompt_response.stop_reason, + )) } /// Whether the agent advertises support for the transport an MCP server needs. @@ -2074,12 +2296,14 @@ mod tests { acp_spawn_command, 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, - remote_acp_segments, resolve_acp_working_dir, resolve_spawn_working_dir, - sanitize_remote_acp_chunk, shell_exec_line, shell_quote, AcpDriver, RemoteLineOutcome, + 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, AgentRunOutcome, RemoteLineOutcome, ReplayBoundary, ReplayBuffer, ReplayEvent, }; use agent_client_protocol::schema::v1::{ ContentBlock as AcpContentBlock, McpCapabilities, McpServer, McpServerHttp, McpServerSse, - McpServerStdio, + McpServerStdio, PermissionOption, PermissionOptionKind, RequestPermissionOutcome, + StopReason, }; use std::ffi::OsString; use std::path::{Path, PathBuf}; @@ -2622,4 +2846,97 @@ 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_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_selects_first_option_before_cancellation() { + let options = vec![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 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 95078d630..ea833d36d 100644 --- a/crates/acp-client/src/lib.rs +++ b/crates/acp-client/src/lib.rs @@ -28,7 +28,7 @@ pub use agent_client_protocol::schema::v1::{ }; pub use driver::{ strip_code_fences, AcpDriver, AcpEventMetadata, AcpInitializeMetadata, AcpToolCallMetadata, - AgentDriver, BasicMessageWriter, MessageWriter, Store, + 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 b54016a2d..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::{AcpDriver, AgentDriver, BasicMessageWriter, MessageWriter}; +use crate::driver::{AcpDriver, AgentDriver, AgentRunOutcome, BasicMessageWriter, MessageWriter}; use crate::types::AcpAgent; /// Minimal store implementation for simple prompting (no persistence). @@ -57,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", From ca2a922e845727a2a65e27d17bfeafd03b5271ed Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 23 Jun 2026 17:33:25 +1000 Subject: [PATCH 05/15] feat(acp): surface rich session metadata in UI Render ACP tool status, structured inputs and outputs, diffs, locations, usage, plans, config selectors, and slash command discovery from existing session_messages metadata. Route ACP permission requests through the Staged UI instead of auto-selecting the first option, while preserving legacy fallback behavior for non-interactive clients. Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/agent/mod.rs | 2 + .../staged/src-tauri/src/agent/permissions.rs | 72 ++ apps/staged/src-tauri/src/agent/writer.rs | 287 +++++- apps/staged/src-tauri/src/lib.rs | 3 + apps/staged/src-tauri/src/session_commands.rs | 15 +- apps/staged/src-tauri/src/session_runner.rs | 14 +- apps/staged/src-tauri/src/web_server.rs | 13 + apps/staged/src/lib/commands.ts | 4 + .../lib/features/sessions/SessionModal.svelte | 841 +++++++++++++----- .../features/sessions/acpTranscript.test.ts | 141 +++ .../lib/features/sessions/acpTranscript.ts | 624 +++++++++++++ crates/acp-client/src/driver.rs | 175 +++- crates/acp-client/src/lib.rs | 5 +- 13 files changed, 1963 insertions(+), 233 deletions(-) create mode 100644 apps/staged/src-tauri/src/agent/permissions.rs create mode 100644 apps/staged/src/lib/features/sessions/acpTranscript.test.ts create mode 100644 apps/staged/src/lib/features/sessions/acpTranscript.ts diff --git a/apps/staged/src-tauri/src/agent/mod.rs b/apps/staged/src-tauri/src/agent/mod.rs index 87b5a83f2..c540119fe 100644 --- a/apps/staged/src-tauri/src/agent/mod.rs +++ b/apps/staged/src-tauri/src/agent/mod.rs @@ -5,9 +5,11 @@ use std::collections::{HashMap, HashSet}; +pub mod permissions; pub mod writer; pub use acp_client::{discover_providers, AcpDriver, AcpProviderInfo, AgentDriver}; +pub use permissions::{PermissionDecision, PermissionRegistry}; use crate::store::{MessageRole, SessionMessage, Store}; diff --git a/apps/staged/src-tauri/src/agent/permissions.rs b/apps/staged/src-tauri/src/agent/permissions.rs new file mode 100644 index 000000000..9abc59966 --- /dev/null +++ b/apps/staged/src-tauri/src/agent/permissions.rs @@ -0,0 +1,72 @@ +use std::collections::HashMap; +use std::sync::Mutex; + +use tokio::sync::oneshot; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PermissionDecision { + Selected { option_id: String }, + Cancelled, +} + +struct PendingPermission { + session_id: String, + sender: oneshot::Sender, +} + +#[derive(Default)] +pub struct PermissionRegistry { + pending: Mutex>, +} + +impl PermissionRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn register( + &self, + request_id: String, + session_id: String, + ) -> oneshot::Receiver { + let (sender, receiver) = oneshot::channel(); + self.pending + .lock() + .unwrap() + .insert(request_id, PendingPermission { session_id, sender }); + receiver + } + + pub fn respond(&self, request_id: &str, decision: PermissionDecision) -> Result<(), String> { + let pending = self + .pending + .lock() + .unwrap() + .remove(request_id) + .ok_or_else(|| "Permission request is no longer pending".to_string())?; + pending + .sender + .send(decision) + .map_err(|_| "Permission request is no longer waiting for a response".to_string()) + } + + pub fn cancel_session(&self, session_id: &str) { + let mut pending = self.pending.lock().unwrap(); + let request_ids: Vec = pending + .iter() + .filter_map(|(request_id, pending)| { + (pending.session_id == session_id).then(|| request_id.clone()) + }) + .collect(); + + for request_id in request_ids { + if let Some(pending) = pending.remove(&request_id) { + let _ = pending.sender.send(PermissionDecision::Cancelled); + } + } + } + + pub fn unregister(&self, request_id: &str) { + self.pending.lock().unwrap().remove(request_id); + } +} diff --git a/apps/staged/src-tauri/src/agent/writer.rs b/apps/staged/src-tauri/src/agent/writer.rs index 6855367aa..2c83f6523 100644 --- a/apps/staged/src-tauri/src/agent/writer.rs +++ b/apps/staged/src-tauri/src/agent/writer.rs @@ -14,10 +14,15 @@ use std::time::{Duration, Instant}; use async_trait::async_trait; use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; +use crate::agent::{PermissionDecision as StagedPermissionDecision, PermissionRegistry}; use crate::store::{AcpMessageMetadata, MessageRole, Store}; -use acp_client::{strip_code_fences, AcpEventMetadata, AcpInitializeMetadata, AcpToolCallMetadata}; +use acp_client::{ + 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 @@ -46,6 +51,7 @@ pub struct MessageWriter { /// 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>, + permission_registry: Option>, } /// Strip backticks from agent-provided tool-call titles. @@ -96,6 +102,55 @@ fn acp_event_role(metadata: &AcpMessageMetadata) -> MessageRole { } } +fn permission_options_json(request: &AcpPermissionRequest) -> Vec { + request + .options + .iter() + .map(|option| { + serde_json::json!({ + "optionId": option.option_id, + "name": option.name, + "kind": option.kind, + }) + }) + .collect() +} + +fn permission_request_content( + request: &AcpPermissionRequest, + status: &str, + selected_option_id: Option<&str>, +) -> serde_json::Value { + serde_json::json!({ + "requestId": request.request_id, + "sessionId": request.session_id, + "toolCallId": request.tool_call_id, + "toolTitle": request.tool_title, + "toolKind": request.tool_kind, + "toolStatus": request.tool_status, + "rawInput": request.raw_input, + "rawOutput": request.raw_output, + "content": request.content, + "locations": request.locations, + "options": permission_options_json(request), + "status": status, + "selectedOptionId": selected_option_id, + "rawRequest": request.raw_request, + }) +} + +fn fallback_permission_decision(request: &AcpPermissionRequest) -> AcpPermissionDecision { + request + .options + .first() + .map(|option| AcpPermissionDecision::Selected { + option_id: option.option_id.clone(), + }) + .unwrap_or(AcpPermissionDecision::Selected { + option_id: "approve".to_string(), + }) +} + impl MessageWriter { pub fn new(session_id: String, store: Arc) -> Self { Self { @@ -106,9 +161,18 @@ impl MessageWriter { last_flush_at: Mutex::new(Instant::now()), tool_call_rows: Mutex::new(HashMap::new()), current_tool_result_msg_id: Mutex::new(None), + permission_registry: None, } } + pub fn with_permission_registry( + mut self, + permission_registry: Arc, + ) -> Self { + self.permission_registry = Some(permission_registry); + self + } + // ===================================================================== // Text streaming // ===================================================================== @@ -391,6 +455,80 @@ impl acp_client::MessageWriter for MessageWriter { log::error!("Failed to persist ACP event metadata: {e}"); } } + + async fn request_permission( + &self, + request: AcpPermissionRequest, + cancel_token: CancellationToken, + ) -> AcpPermissionDecision { + let Some(registry) = &self.permission_registry else { + return if cancel_token.is_cancelled() { + AcpPermissionDecision::Cancelled + } else { + fallback_permission_decision(&request) + }; + }; + + let request_metadata = AcpMessageMetadata { + acp_event_kind: Some("permission_request".to_string()), + acp_tool_call_id: Some(request.tool_call_id.clone()), + acp_tool_kind: request.tool_kind.clone(), + acp_tool_status: request.tool_status.clone(), + acp_raw_input: request.raw_input.clone(), + acp_raw_output: request.raw_output.clone(), + acp_content: Some(permission_request_content(&request, "pending", None)), + acp_locations: request.locations.clone(), + ..Default::default() + }; + if let Err(e) = self + .store + .add_acp_metadata_message(&self.session_id, &request_metadata) + { + log::error!("Failed to persist ACP permission request: {e}"); + } + + let receiver = registry.register(request.request_id.clone(), request.session_id.clone()); + let staged_decision = tokio::select! { + _ = cancel_token.cancelled() => StagedPermissionDecision::Cancelled, + decision = receiver => decision.unwrap_or(StagedPermissionDecision::Cancelled), + }; + registry.unregister(&request.request_id); + + let (status, selected_option_id, decision) = match staged_decision { + StagedPermissionDecision::Selected { option_id } => ( + "selected", + Some(option_id.clone()), + AcpPermissionDecision::Selected { option_id }, + ), + StagedPermissionDecision::Cancelled => { + ("cancelled", None, AcpPermissionDecision::Cancelled) + } + }; + + let response_metadata = AcpMessageMetadata { + acp_event_kind: Some("permission_response".to_string()), + acp_tool_call_id: Some(request.tool_call_id.clone()), + acp_tool_kind: request.tool_kind.clone(), + acp_tool_status: request.tool_status.clone(), + acp_raw_input: request.raw_input.clone(), + acp_raw_output: request.raw_output.clone(), + acp_content: Some(permission_request_content( + &request, + status, + selected_option_id.as_deref(), + )), + acp_locations: request.locations.clone(), + ..Default::default() + }; + if let Err(e) = self + .store + .add_acp_metadata_message(&self.session_id, &response_metadata) + { + log::error!("Failed to persist ACP permission response: {e}"); + } + + decision + } } #[cfg(test)] @@ -399,7 +537,10 @@ mod tests { use std::sync::Arc; use super::MessageWriter; + use crate::agent::{PermissionDecision, PermissionRegistry}; use crate::store::{MessageRole, Session, Store}; + use acp_client::{AcpPermissionDecision, AcpPermissionOption, AcpPermissionRequest}; + use tokio_util::sync::CancellationToken; fn setup_writer() -> (Arc, String, MessageWriter) { let store = Arc::new(Store::in_memory().expect("in-memory store")); @@ -409,6 +550,150 @@ 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: "allow_once".to_string(), + }, + AcpPermissionOption { + option_id: "reject-once".to_string(), + name: "Deny".to_string(), + kind: "reject_once".to_string(), + }, + ], + raw_request: None, + } + } + + async fn answer_permission( + registry: Arc, + request_id: &'static str, + decision: PermissionDecision, + ) { + for _ in 0..20 { + match registry.respond(request_id, decision.clone()) { + Ok(()) => return, + Err(_) => tokio::time::sleep(std::time::Duration::from_millis(5)).await, + } + } + panic!("permission request was not registered"); + } + + #[tokio::test] + async fn permission_request_returns_approved_option() { + let (store, session_id, writer) = setup_writer(); + let registry = Arc::new(PermissionRegistry::new()); + let writer = writer.with_permission_registry(Arc::clone(®istry)); + let request = permission_request(&session_id, "approve-request"); + let cancel_token = CancellationToken::new(); + + let task = tokio::spawn(async move { + ::request_permission( + &writer, + request, + cancel_token, + ) + .await + }); + answer_permission( + registry, + "approve-request", + PermissionDecision::Selected { + option_id: "allow-once".to_string(), + }, + ) + .await; + + assert_eq!( + task.await.expect("permission task"), + AcpPermissionDecision::Selected { + option_id: "allow-once".to_string() + } + ); + let metadata = store + .get_session_acp_metadata_messages(&session_id) + .expect("metadata"); + assert_eq!(metadata.len(), 2); + assert_eq!( + metadata[0].acp.acp_event_kind.as_deref(), + Some("permission_request") + ); + assert_eq!( + metadata[1].acp.acp_event_kind.as_deref(), + Some("permission_response") + ); + } + + #[tokio::test] + async fn permission_request_returns_denied_option() { + let (_store, session_id, writer) = setup_writer(); + let registry = Arc::new(PermissionRegistry::new()); + let writer = writer.with_permission_registry(Arc::clone(®istry)); + let request = permission_request(&session_id, "deny-request"); + let cancel_token = CancellationToken::new(); + + let task = tokio::spawn(async move { + ::request_permission( + &writer, + request, + cancel_token, + ) + .await + }); + answer_permission( + registry, + "deny-request", + PermissionDecision::Selected { + option_id: "reject-once".to_string(), + }, + ) + .await; + + assert_eq!( + task.await.expect("permission task"), + AcpPermissionDecision::Selected { + option_id: "reject-once".to_string() + } + ); + } + + #[tokio::test] + async fn permission_request_returns_cancelled_outcome() { + let (_store, session_id, writer) = setup_writer(); + let registry = Arc::new(PermissionRegistry::new()); + let writer = writer.with_permission_registry(Arc::clone(®istry)); + let request = permission_request(&session_id, "cancel-request"); + let cancel_token = CancellationToken::new(); + + let task = tokio::spawn(async move { + ::request_permission( + &writer, + request, + cancel_token, + ) + .await + }); + answer_permission(registry, "cancel-request", PermissionDecision::Cancelled).await; + + assert_eq!( + task.await.expect("permission task"), + AcpPermissionDecision::Cancelled + ); + } + #[tokio::test] async fn record_tool_result_updates_existing_row_for_streaming_updates() { let (store, session_id, writer) = setup_writer(); diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index cc113ea53..5c91dc985 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -2041,6 +2041,7 @@ pub fn run() { let compat = store::check_db_compatibility(&db_path) .map_err(|e| format!("Cannot check database: {e}"))?; let session_registry = Arc::new(session_runner::SessionRegistry::new()); + let permission_registry = Arc::new(agent::PermissionRegistry::new()); // Backend-owned PR-poll scheduler. Managed unconditionally so the // interest/hint commands resolve even before the store exists (e.g. // during the needs-reset prompt); the tick loop is only spawned once @@ -2120,6 +2121,7 @@ pub fn run() { app.manage(store_slot); app.manage(session_registry); + app.manage(permission_registry); app.manage(pr_scheduler); app.manage(Arc::new(actions::ActionExecutor::new())); app.manage(Arc::new(actions::ActionRegistry::new())); @@ -2315,6 +2317,7 @@ pub fn run() { session_commands::get_session_messages_since, session_commands::get_session_acp_metadata_messages, session_commands::get_session_acp_initialization, + session_commands::respond_acp_permission, session_commands::count_assistant_messages_after, session_commands::start_session, session_commands::resume_session, diff --git a/apps/staged/src-tauri/src/session_commands.rs b/apps/staged/src-tauri/src/session_commands.rs index fe0e6a0d3..2798f450b 100644 --- a/apps/staged/src-tauri/src/session_commands.rs +++ b/apps/staged/src-tauri/src/session_commands.rs @@ -25,7 +25,7 @@ use tauri::path::BaseDirectory; use tauri::Manager; use crate::actions::{ActionExecutor, ActionRegistry}; -use crate::agent::{self, AcpProviderInfo}; +use crate::agent::{self, AcpProviderInfo, PermissionDecision, PermissionRegistry}; use crate::blox; use crate::git; use crate::session_runner::{self, SessionConfig}; @@ -376,6 +376,19 @@ pub fn get_session_acp_initialization( .map_err(|e| e.to_string()) } +#[tauri::command] +pub fn respond_acp_permission( + registry: tauri::State<'_, Arc>, + request_id: String, + option_id: Option, +) -> Result<(), String> { + let decision = match option_id { + Some(option_id) => PermissionDecision::Selected { option_id }, + None => PermissionDecision::Cancelled, + }; + registry.respond(&request_id, decision) +} + #[tauri::command] pub fn count_assistant_messages_after( store: tauri::State<'_, Mutex>>>, diff --git a/apps/staged/src-tauri/src/session_runner.rs b/apps/staged/src-tauri/src/session_runner.rs index 0f91b200c..276dc16e7 100644 --- a/apps/staged/src-tauri/src/session_runner.rs +++ b/apps/staged/src-tauri/src/session_runner.rs @@ -41,14 +41,14 @@ use std::sync::{Arc, OnceLock}; use std::time::Duration; use serde::{Deserialize, Serialize}; -use tauri::AppHandle; +use tauri::{AppHandle, Manager}; use tokio::io::{AsyncRead, AsyncReadExt}; use tokio_util::sync::CancellationToken; use acp_client::{AgentRunOutcome, McpServer, McpServerHttp}; use crate::actions::{ActionExecutor, ActionRegistry}; -use crate::agent::{AcpDriver, AgentDriver, MessageWriter}; +use crate::agent::{AcpDriver, AgentDriver, MessageWriter, PermissionRegistry}; use crate::git::Span; use crate::shell_env::ShellEnvCache; use crate::store::{ @@ -362,6 +362,8 @@ pub fn start_session( app_handle: AppHandle, registry: Arc, ) -> Result<(), String> { + let permission_registry = Arc::clone(&app_handle.state::>()); + // Create the driver eagerly so we fail fast if the agent isn't found. // Local sessions without an explicit provider resolve the first available // provider and persist it on the session. Review-producing callers resolve @@ -617,10 +619,10 @@ pub fn start_session( let mut include_images = true; loop { - let writer = Arc::new(MessageWriter::new( - config.session_id.clone(), - Arc::clone(&store), - )); + let writer = Arc::new( + MessageWriter::new(config.session_id.clone(), Arc::clone(&store)) + .with_permission_registry(Arc::clone(&permission_registry)), + ); let store_trait: Arc = store.clone(); let writer_trait: Arc = writer; let images = if include_images { diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 5c974edc0..7aacb57e3 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -44,6 +44,7 @@ use tower_http::cors::CorsLayer; use tower_http::services::ServeDir; use crate::actions::{ActionExecutor, ActionRegistry}; +use crate::agent::{PermissionDecision, PermissionRegistry}; use crate::pr_poll_scheduler::PrPollScheduler; use crate::session_commands; use crate::session_runner::{self, SessionConfig, SessionRegistry}; @@ -448,6 +449,8 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result>> = &app_handle.state::>>>(); let session_registry: &Arc = &app_handle.state::>(); + let permission_registry: &Arc = + &app_handle.state::>(); let action_executor: &Arc = &app_handle.state::>(); let action_registry: &Arc = &app_handle.state::>(); let pr_scheduler: &Arc = &app_handle.state::>(); @@ -2806,6 +2809,16 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result { + let request_id: String = arg(&args, "requestId")?; + let option_id: Option = opt_arg(&args, "optionId")?; + let decision = match option_id { + Some(option_id) => PermissionDecision::Selected { option_id }, + None => PermissionDecision::Cancelled, + }; + permission_registry.respond(&request_id, decision)?; + Ok(Value::Null) + } "start_session" => { let store = get_store(store_mutex)?; let prompt: String = arg(&args, "prompt")?; diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index ace96f378..0945ef83d 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -733,6 +733,10 @@ export function getSessionAcpMetadataMessages(sessionId: string): Promise { + return invokeCommand('respond_acp_permission', { requestId, optionId }); +} + 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..1160cf37f 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,9 +57,11 @@ deleteImage, getImageData, getSession, + getSessionAcpMetadataMessages, getSessionMessages, getSessionMessagesSince, handleExternalLinkClick, + respondAcpPermission, resumeSession, } from '../../api/commands'; import HashtagInput from './HashtagInput.svelte'; @@ -72,15 +79,24 @@ 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, + latestAvailableCommands, + permissionOptions, + permissionRequestId, + permissionStatus, + permissionToolTitle, + simpleUnifiedDiff, + terminalRefsFromAcpContent, + toolResultText, + type AcpCommand, + type AcpTranscriptEvent, + type AcpTranscriptGroup, + } from './acpTranscript'; import { displayRootKey, normalizeDisplayRoots, @@ -152,6 +168,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 +181,8 @@ 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 permissionSubmitting = $state>(new Set()); let displayRoots = $state([]); let currentDisplayRootKey = ''; @@ -493,13 +510,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 +536,7 @@ }) .catch(() => {}); } + acpMetadataMessages = acpMsgs; } catch (e) { error = e instanceof Error ? e.message : String(e); } finally { @@ -542,16 +562,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 +758,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 +1082,167 @@ 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(); + }); + } + + async function handlePermissionResponse(content: unknown, optionId: string | null) { + const requestId = permissionRequestId(content); + if (!requestId || permissionSubmitting.has(requestId)) return; + permissionSubmitting = new Set(permissionSubmitting).add(requestId); + try { + await respondAcpPermission(requestId, optionId); + acpMetadataMessages = await getSessionAcpMetadataMessages(sessionId); + } catch (e) { + error = `Failed to answer permission prompt: ${e instanceof Error ? e.message : String(e)}`; + } finally { + const next = new Set(permissionSubmitting); + next.delete(requestId); + permissionSubmitting = next; } - return arr; - }); + } - /** - * 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 isPermissionSubmitting(content: unknown): boolean { + const requestId = permissionRequestId(content); + return !!requestId && permissionSubmitting.has(requestId); + } + + function permissionOptionVariant(kind: string): 'outline' | 'destructive' | 'ghost' { + if (kind.startsWith('reject')) return 'destructive'; + if (kind.startsWith('allow')) return 'outline'; + return 'ghost'; + } + + 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 cache; - }); + if (event.kind === 'available_commands_update') { + const commands = arrayProp(event.content, 'availableCommands'); + return commands.length === 1 ? '1 command' : `${commands.length} commands`; + } + if (event.kind === 'permission_request') { + return permissionStatus(event.content) === 'pending' + ? 'Waiting for response' + : sentenceCase(permissionStatus(event.content)); + } + 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); + } - /** 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 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 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; + } + + function sentenceCase(value: string): string { + return value ? value.slice(0, 1).toUpperCase() + value.slice(1).replaceAll('_', ' ') : ''; + } + + 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. */ @@ -1261,7 +1342,7 @@ {: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 +1450,205 @@
- {: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)} + {#each group.items as item (item.key)} + {@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)} + > + - - {item.verb} - {#if item.detail} - {item.detail} + + {#if item.statusTone === 'running'} + + {:else if item.statusTone === 'success'} + + {:else if item.statusTone === 'danger'} + + {:else if item.statusTone === 'cancelled'} + + {:else} + {/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 -
-
+ + {item.verb} + {#if item.detail} + {item.detail} {/if} + {item.statusLabel}
- {:else} - {@const verbGroupKey = `${vg.items[0].pair.call.id}-${vg.verb}`} - {@const isGroupExpanded = expandedVerbGroups.has(verbGroupKey)} -
- - + {#if isExpanded && hasDetails}
toggleVerbGroup(verbGroupKey)} + class="tool-code-block" + transition:slide={{ duration: SLIDE_DURATION }} > - - {vg.verb} - {verbGroupSummary(vg)} -
-
- {#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} + {#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} - {/if} +
{/each}
+ {:else} + {@const event = group.event} + {@const isExpanded = expandedTools.has(`event:${event.id}`)} +
+
+ + +
toggleTool(`event:${event.id}`)}> + + {event.title} + {acpEventSummary(event)} +
+ {#if event.kind === 'permission_request'} + {@const status = permissionStatus(event.content)} + {@const requestId = permissionRequestId(event.content)} + {@const submitting = isPermissionSubmitting(event.content)} +
+
+ {permissionToolTitle(event.content, displayRoots)} +
+ {#if status === 'pending' && requestId} +
+ {#each permissionOptions(event.content) as option} + + {/each} + +
+ {/if} +
+ {/if} + {#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 +1754,16 @@ {/each}
{/if} + {#if matchingSlashCommands.length > 0 && !isLive} +
+ {#each matchingSlashCommands as command} + + {/each} +
+ {/if} {#if canAttachImages && replyImageIds.length > 0}
{#each replyImageIds as imageId} @@ -1972,10 +2156,6 @@ min-width: 0; } - .tool-card-nested { - padding-left: 16px; - } - .tool-header { display: flex; align-items: center; @@ -2022,6 +2202,39 @@ 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-status-label { + flex-shrink: 0; + margin-left: auto; + color: var(--text-faint); + font-size: calc(var(--size-xs) * 0.88); + } + .tool-args-preview { flex: 1; min-width: 0; @@ -2059,6 +2272,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 +2320,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 +2351,150 @@ 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, + .permission-card-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; + } + + .permission-tool-title { + color: var(--text-primary); + font-size: var(--size-sm); + line-height: 1.4; + word-break: break-word; + } + + .permission-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; + } + + .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 +2519,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..7069612ca --- /dev/null +++ b/apps/staged/src/lib/features/sessions/acpTranscript.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; +import type { SessionMessage } from '../../types'; +import { buildAcpTranscriptGroups, latestAvailableCommands } 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('uses permission responses to resolve pending permission request cards', () => { + 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).toHaveLength(1); + expect(groups[0].type).toBe('acp'); + if (groups[0].type === 'acp') { + expect(groups[0].event.content).toMatchObject({ + requestId: 'perm-1', + status: 'selected', + }); + } + }); +}); + +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', + }, + ]); + }); +}); 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..5949da8c3 --- /dev/null +++ b/apps/staged/src/lib/features/sessions/acpTranscript.ts @@ -0,0 +1,624 @@ +import type { SessionMessage } from '../../types'; +import type { DisplayRootInput } from './pathDisplayRoots'; +import { formatToolDisplay, makePathsRelative, stripCodeFences } 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 type AcpTranscriptEventKind = + | 'plan_update' + | 'usage_update' + | 'prompt_response' + | 'config_options_update' + | 'session_mode_state' + | 'current_mode_update' + | 'available_commands_update' + | 'session_info_update' + | 'permission_request'; + +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 STANDALONE_EVENT_KINDS = new Set([ + 'plan_update', + 'usage_update', + 'prompt_response', + 'config_options_update', + 'session_mode_state', + 'current_mode_update', + 'available_commands_update', + 'session_info_update', + 'permission_request', +]); + +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 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 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 permissionResponses = new Map(); + for (const row of metadataRows) { + if (row.acpEventKind !== 'permission_response') continue; + const requestId = stringProp(row.acpContent, 'requestId'); + if (requestId) permissionResponses.set(requestId, row.acpContent); + } + + const events: AcpTranscriptEvent[] = []; + for (const row of metadataRows) { + const kind = row.acpEventKind as AcpTranscriptEventKind | undefined; + if (!kind || !STANDALONE_EVENT_KINDS.has(kind)) continue; + let content = eventContent(row); + if (kind === 'permission_request') { + const requestId = stringProp(row.acpContent, 'requestId'); + content = (requestId && permissionResponses.get(requestId)) || row.acpContent; + } + events.push({ + id: row.id, + kind, + title: eventTitle(kind), + content, + 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'; + case 'permission_request': + return 'Permission'; + } +} + +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 permissionStatus(content: unknown): string { + return stringProp(content, 'status') ?? 'pending'; +} + +export function permissionRequestId(content: unknown): string | null { + return stringProp(content, 'requestId'); +} + +export function permissionOptions( + content: unknown +): { optionId: string; name: string; kind: string }[] { + const options = ( + content && typeof content === 'object' ? (content as Record).options : null + ) as unknown; + if (!Array.isArray(options)) return []; + return options + .map((option) => { + const optionId = stringProp(option, 'optionId'); + const name = stringProp(option, 'name'); + const kind = stringProp(option, 'kind') ?? ''; + return optionId && name ? { optionId, name, kind } : null; + }) + .filter( + (option): option is { optionId: string; name: string; kind: string } => option !== null + ); +} + +export function permissionToolTitle(content: unknown, displayRoots?: DisplayRootInput): string { + return makePathsRelative(stringProp(content, 'toolTitle') ?? '', displayRoots); +} + +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/crates/acp-client/src/driver.rs b/crates/acp-client/src/driver.rs index 2943978e1..584452bdd 100644 --- a/crates/acp-client/src/driver.rs +++ b/crates/acp-client/src/driver.rs @@ -12,6 +12,7 @@ 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}; @@ -44,6 +45,8 @@ use nix::unistd::Pid; use crate::types::blox_acp_command; +static PERMISSION_REQUEST_COUNTER: AtomicU64 = AtomicU64::new(1); + // ============================================================================= // Public traits and types // ============================================================================= @@ -106,6 +109,30 @@ pub trait MessageWriter: Send + Sync { /// 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 keep the legacy behavior by + /// selecting the first option 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 { + 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)] @@ -148,6 +175,35 @@ pub struct AcpEventMetadata { 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: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AcpPermissionDecision { + Selected { option_id: String }, + Cancelled, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReplayBoundary { pub role: String, @@ -1443,10 +1499,18 @@ impl AcpNotificationHandler { &self, args: RequestPermissionRequest, ) -> agent_client_protocol::Result { - Ok(permission_response_for_options( - &args.options, - self.permission_cancel_token.is_cancelled(), - )) + if self.permission_cancel_token.is_cancelled() { + return Ok(RequestPermissionResponse::new( + RequestPermissionOutcome::Cancelled, + )); + } + + 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( @@ -1465,6 +1529,36 @@ impl 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::Plan(plan) => { + self.writer + .record_acp_event_metadata(AcpEventMetadata { + event_kind: Some("plan_update".to_string()), + content: serialize_value(plan), + ..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(()); + } _ => {} } @@ -1746,12 +1840,13 @@ impl AcpNotificationHandler { } } -fn permission_response_for_options( +#[cfg(test)] +fn permission_decision_for_options( options: &[agent_client_protocol::schema::v1::PermissionOption], cancelled: bool, -) -> RequestPermissionResponse { +) -> AcpPermissionDecision { if cancelled { - return RequestPermissionResponse::new(RequestPermissionOutcome::Cancelled); + return AcpPermissionDecision::Cancelled; } let option_id = options @@ -1759,9 +1854,69 @@ fn permission_response_for_options( .map(|opt| opt.option_id.clone()) .unwrap_or_else(|| PermissionOptionId::new("approve")); - RequestPermissionResponse::new(RequestPermissionOutcome::Selected( - SelectedPermissionOutcome::new(option_id), - )) + AcpPermissionDecision::Selected { + option_id: option_id.0.as_ref().to_string(), + } +} + +#[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_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(|option| AcpPermissionOption { + option_id: option.option_id.0.as_ref().to_string(), + name: option.name.clone(), + kind: serialize_as_string(&option.kind).unwrap_or_else(|| "unknown".to_string()), + }) + .collect(), + raw_request: serialize_value(args), + } } /// Extract the tool-call ID from a session update, if it carries one. diff --git a/crates/acp-client/src/lib.rs b/crates/acp-client/src/lib.rs index ea833d36d..20608d937 100644 --- a/crates/acp-client/src/lib.rs +++ b/crates/acp-client/src/lib.rs @@ -27,8 +27,9 @@ pub use agent_client_protocol::schema::v1::{ SessionConfigOptionCategory, SessionInfoUpdate, SessionModeState as SessionModelState, }; pub use driver::{ - strip_code_fences, AcpDriver, AcpEventMetadata, AcpInitializeMetadata, AcpToolCallMetadata, - AgentDriver, AgentRunOutcome, BasicMessageWriter, MessageWriter, ReplayBoundary, Store, + strip_code_fences, AcpDriver, AcpEventMetadata, AcpInitializeMetadata, AcpPermissionDecision, + AcpPermissionOption, AcpPermissionRequest, AcpToolCallMetadata, AgentDriver, AgentRunOutcome, + BasicMessageWriter, MessageWriter, ReplayBoundary, Store, }; pub use simple::{run_acp_prompt, run_acp_prompt_with_interpreter_env_snapshot}; pub use types::{ From 21c82c415e1788740b0495b36ed38d02d94ff8bb Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 25 Jun 2026 10:32:17 +1000 Subject: [PATCH 06/15] feat(acp): autoapprove permission requests Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/agent/mod.rs | 2 - .../staged/src-tauri/src/agent/permissions.rs | 72 ------ apps/staged/src-tauri/src/agent/writer.rs | 240 ++++-------------- apps/staged/src-tauri/src/lib.rs | 3 - apps/staged/src-tauri/src/session_commands.rs | 15 +- apps/staged/src-tauri/src/session_runner.rs | 14 +- apps/staged/src-tauri/src/web_server.rs | 13 - apps/staged/src/lib/commands.ts | 4 - .../lib/features/sessions/SessionModal.svelte | 92 +------ .../features/sessions/acpTranscript.test.ts | 11 +- .../lib/features/sessions/acpTranscript.ts | 51 +--- crates/acp-client/src/driver.rs | 67 +++-- 12 files changed, 107 insertions(+), 477 deletions(-) delete mode 100644 apps/staged/src-tauri/src/agent/permissions.rs diff --git a/apps/staged/src-tauri/src/agent/mod.rs b/apps/staged/src-tauri/src/agent/mod.rs index c540119fe..87b5a83f2 100644 --- a/apps/staged/src-tauri/src/agent/mod.rs +++ b/apps/staged/src-tauri/src/agent/mod.rs @@ -5,11 +5,9 @@ use std::collections::{HashMap, HashSet}; -pub mod permissions; pub mod writer; pub use acp_client::{discover_providers, AcpDriver, AcpProviderInfo, AgentDriver}; -pub use permissions::{PermissionDecision, PermissionRegistry}; use crate::store::{MessageRole, SessionMessage, Store}; diff --git a/apps/staged/src-tauri/src/agent/permissions.rs b/apps/staged/src-tauri/src/agent/permissions.rs deleted file mode 100644 index 9abc59966..000000000 --- a/apps/staged/src-tauri/src/agent/permissions.rs +++ /dev/null @@ -1,72 +0,0 @@ -use std::collections::HashMap; -use std::sync::Mutex; - -use tokio::sync::oneshot; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PermissionDecision { - Selected { option_id: String }, - Cancelled, -} - -struct PendingPermission { - session_id: String, - sender: oneshot::Sender, -} - -#[derive(Default)] -pub struct PermissionRegistry { - pending: Mutex>, -} - -impl PermissionRegistry { - pub fn new() -> Self { - Self::default() - } - - pub fn register( - &self, - request_id: String, - session_id: String, - ) -> oneshot::Receiver { - let (sender, receiver) = oneshot::channel(); - self.pending - .lock() - .unwrap() - .insert(request_id, PendingPermission { session_id, sender }); - receiver - } - - pub fn respond(&self, request_id: &str, decision: PermissionDecision) -> Result<(), String> { - let pending = self - .pending - .lock() - .unwrap() - .remove(request_id) - .ok_or_else(|| "Permission request is no longer pending".to_string())?; - pending - .sender - .send(decision) - .map_err(|_| "Permission request is no longer waiting for a response".to_string()) - } - - pub fn cancel_session(&self, session_id: &str) { - let mut pending = self.pending.lock().unwrap(); - let request_ids: Vec = pending - .iter() - .filter_map(|(request_id, pending)| { - (pending.session_id == session_id).then(|| request_id.clone()) - }) - .collect(); - - for request_id in request_ids { - if let Some(pending) = pending.remove(&request_id) { - let _ = pending.sender.send(PermissionDecision::Cancelled); - } - } - } - - pub fn unregister(&self, request_id: &str) { - self.pending.lock().unwrap().remove(request_id); - } -} diff --git a/apps/staged/src-tauri/src/agent/writer.rs b/apps/staged/src-tauri/src/agent/writer.rs index 2c83f6523..2ceca82af 100644 --- a/apps/staged/src-tauri/src/agent/writer.rs +++ b/apps/staged/src-tauri/src/agent/writer.rs @@ -16,12 +16,11 @@ use async_trait::async_trait; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; -use crate::agent::{PermissionDecision as StagedPermissionDecision, PermissionRegistry}; use crate::store::{AcpMessageMetadata, MessageRole, Store}; use acp_client::{ strip_code_fences, AcpEventMetadata, AcpInitializeMetadata, AcpPermissionDecision, - AcpPermissionRequest, AcpToolCallMetadata, + AcpPermissionOption, AcpPermissionRequest, AcpToolCallMetadata, }; /// Minimum interval between DB flushes for streaming text. Chunks accumulate @@ -51,7 +50,6 @@ pub struct MessageWriter { /// 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>, - permission_registry: Option>, } /// Strip backticks from agent-provided tool-call titles. @@ -102,47 +100,25 @@ fn acp_event_role(metadata: &AcpMessageMetadata) -> MessageRole { } } -fn permission_options_json(request: &AcpPermissionRequest) -> Vec { - request - .options - .iter() - .map(|option| { - serde_json::json!({ - "optionId": option.option_id, - "name": option.name, - "kind": option.kind, - }) - }) - .collect() +fn permission_option_is_approval(option: &AcpPermissionOption) -> bool { + let kind = option.kind.to_ascii_lowercase(); + let option_id = option.option_id.to_ascii_lowercase(); + let name = option.name.to_ascii_lowercase(); + + kind.starts_with("allow") + || kind.starts_with("approve") + || option_id.starts_with("allow") + || option_id.starts_with("approve") + || name.contains("allow") + || name.contains("approve") } -fn permission_request_content( - request: &AcpPermissionRequest, - status: &str, - selected_option_id: Option<&str>, -) -> serde_json::Value { - serde_json::json!({ - "requestId": request.request_id, - "sessionId": request.session_id, - "toolCallId": request.tool_call_id, - "toolTitle": request.tool_title, - "toolKind": request.tool_kind, - "toolStatus": request.tool_status, - "rawInput": request.raw_input, - "rawOutput": request.raw_output, - "content": request.content, - "locations": request.locations, - "options": permission_options_json(request), - "status": status, - "selectedOptionId": selected_option_id, - "rawRequest": request.raw_request, - }) -} - -fn fallback_permission_decision(request: &AcpPermissionRequest) -> AcpPermissionDecision { +fn autoapprove_permission_decision(request: &AcpPermissionRequest) -> AcpPermissionDecision { request .options - .first() + .iter() + .find(|option| permission_option_is_approval(option)) + .or_else(|| request.options.first()) .map(|option| AcpPermissionDecision::Selected { option_id: option.option_id.clone(), }) @@ -161,18 +137,9 @@ impl MessageWriter { last_flush_at: Mutex::new(Instant::now()), tool_call_rows: Mutex::new(HashMap::new()), current_tool_result_msg_id: Mutex::new(None), - permission_registry: None, } } - pub fn with_permission_registry( - mut self, - permission_registry: Arc, - ) -> Self { - self.permission_registry = Some(permission_registry); - self - } - // ===================================================================== // Text streaming // ===================================================================== @@ -461,73 +428,11 @@ impl acp_client::MessageWriter for MessageWriter { request: AcpPermissionRequest, cancel_token: CancellationToken, ) -> AcpPermissionDecision { - let Some(registry) = &self.permission_registry else { - return if cancel_token.is_cancelled() { - AcpPermissionDecision::Cancelled - } else { - fallback_permission_decision(&request) - }; - }; - - let request_metadata = AcpMessageMetadata { - acp_event_kind: Some("permission_request".to_string()), - acp_tool_call_id: Some(request.tool_call_id.clone()), - acp_tool_kind: request.tool_kind.clone(), - acp_tool_status: request.tool_status.clone(), - acp_raw_input: request.raw_input.clone(), - acp_raw_output: request.raw_output.clone(), - acp_content: Some(permission_request_content(&request, "pending", None)), - acp_locations: request.locations.clone(), - ..Default::default() - }; - if let Err(e) = self - .store - .add_acp_metadata_message(&self.session_id, &request_metadata) - { - log::error!("Failed to persist ACP permission request: {e}"); - } - - let receiver = registry.register(request.request_id.clone(), request.session_id.clone()); - let staged_decision = tokio::select! { - _ = cancel_token.cancelled() => StagedPermissionDecision::Cancelled, - decision = receiver => decision.unwrap_or(StagedPermissionDecision::Cancelled), - }; - registry.unregister(&request.request_id); - - let (status, selected_option_id, decision) = match staged_decision { - StagedPermissionDecision::Selected { option_id } => ( - "selected", - Some(option_id.clone()), - AcpPermissionDecision::Selected { option_id }, - ), - StagedPermissionDecision::Cancelled => { - ("cancelled", None, AcpPermissionDecision::Cancelled) - } - }; - - let response_metadata = AcpMessageMetadata { - acp_event_kind: Some("permission_response".to_string()), - acp_tool_call_id: Some(request.tool_call_id.clone()), - acp_tool_kind: request.tool_kind.clone(), - acp_tool_status: request.tool_status.clone(), - acp_raw_input: request.raw_input.clone(), - acp_raw_output: request.raw_output.clone(), - acp_content: Some(permission_request_content( - &request, - status, - selected_option_id.as_deref(), - )), - acp_locations: request.locations.clone(), - ..Default::default() - }; - if let Err(e) = self - .store - .add_acp_metadata_message(&self.session_id, &response_metadata) - { - log::error!("Failed to persist ACP permission response: {e}"); + if cancel_token.is_cancelled() { + AcpPermissionDecision::Cancelled + } else { + autoapprove_permission_decision(&request) } - - decision } } @@ -537,7 +442,6 @@ mod tests { use std::sync::Arc; use super::MessageWriter; - use crate::agent::{PermissionDecision, PermissionRegistry}; use crate::store::{MessageRole, Session, Store}; use acp_client::{AcpPermissionDecision, AcpPermissionOption, AcpPermissionRequest}; use tokio_util::sync::CancellationToken; @@ -578,47 +482,21 @@ mod tests { } } - async fn answer_permission( - registry: Arc, - request_id: &'static str, - decision: PermissionDecision, - ) { - for _ in 0..20 { - match registry.respond(request_id, decision.clone()) { - Ok(()) => return, - Err(_) => tokio::time::sleep(std::time::Duration::from_millis(5)).await, - } - } - panic!("permission request was not registered"); - } - #[tokio::test] - async fn permission_request_returns_approved_option() { + async fn permission_request_autoapproves_without_ui_metadata() { let (store, session_id, writer) = setup_writer(); - let registry = Arc::new(PermissionRegistry::new()); - let writer = writer.with_permission_registry(Arc::clone(®istry)); - let request = permission_request(&session_id, "approve-request"); + let request = permission_request(&session_id, "autoapprove-request"); let cancel_token = CancellationToken::new(); - let task = tokio::spawn(async move { - ::request_permission( - &writer, - request, - cancel_token, - ) - .await - }); - answer_permission( - registry, - "approve-request", - PermissionDecision::Selected { - option_id: "allow-once".to_string(), - }, + let decision = ::request_permission( + &writer, + request, + cancel_token, ) .await; assert_eq!( - task.await.expect("permission task"), + decision, AcpPermissionDecision::Selected { option_id: "allow-once".to_string() } @@ -626,72 +504,46 @@ mod tests { let metadata = store .get_session_acp_metadata_messages(&session_id) .expect("metadata"); - assert_eq!(metadata.len(), 2); - assert_eq!( - metadata[0].acp.acp_event_kind.as_deref(), - Some("permission_request") - ); - assert_eq!( - metadata[1].acp.acp_event_kind.as_deref(), - Some("permission_response") - ); + assert!(metadata.is_empty()); } #[tokio::test] - async fn permission_request_returns_denied_option() { + async fn permission_request_prefers_allow_option_over_first_option() { let (_store, session_id, writer) = setup_writer(); - let registry = Arc::new(PermissionRegistry::new()); - let writer = writer.with_permission_registry(Arc::clone(®istry)); - let request = permission_request(&session_id, "deny-request"); + let mut request = permission_request(&session_id, "allow-request"); + request.options.reverse(); let cancel_token = CancellationToken::new(); - let task = tokio::spawn(async move { - ::request_permission( - &writer, - request, - cancel_token, - ) - .await - }); - answer_permission( - registry, - "deny-request", - PermissionDecision::Selected { - option_id: "reject-once".to_string(), - }, + let decision = ::request_permission( + &writer, + request, + cancel_token, ) .await; assert_eq!( - task.await.expect("permission task"), + decision, AcpPermissionDecision::Selected { - option_id: "reject-once".to_string() + option_id: "allow-once".to_string() } ); } #[tokio::test] - async fn permission_request_returns_cancelled_outcome() { + async fn permission_request_returns_cancelled_when_already_cancelled() { let (_store, session_id, writer) = setup_writer(); - let registry = Arc::new(PermissionRegistry::new()); - let writer = writer.with_permission_registry(Arc::clone(®istry)); let request = permission_request(&session_id, "cancel-request"); let cancel_token = CancellationToken::new(); + cancel_token.cancel(); - let task = tokio::spawn(async move { - ::request_permission( - &writer, - request, - cancel_token, - ) - .await - }); - answer_permission(registry, "cancel-request", PermissionDecision::Cancelled).await; + let decision = ::request_permission( + &writer, + request, + cancel_token, + ) + .await; - assert_eq!( - task.await.expect("permission task"), - AcpPermissionDecision::Cancelled - ); + assert_eq!(decision, AcpPermissionDecision::Cancelled); } #[tokio::test] diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 5c91dc985..cc113ea53 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -2041,7 +2041,6 @@ pub fn run() { let compat = store::check_db_compatibility(&db_path) .map_err(|e| format!("Cannot check database: {e}"))?; let session_registry = Arc::new(session_runner::SessionRegistry::new()); - let permission_registry = Arc::new(agent::PermissionRegistry::new()); // Backend-owned PR-poll scheduler. Managed unconditionally so the // interest/hint commands resolve even before the store exists (e.g. // during the needs-reset prompt); the tick loop is only spawned once @@ -2121,7 +2120,6 @@ pub fn run() { app.manage(store_slot); app.manage(session_registry); - app.manage(permission_registry); app.manage(pr_scheduler); app.manage(Arc::new(actions::ActionExecutor::new())); app.manage(Arc::new(actions::ActionRegistry::new())); @@ -2317,7 +2315,6 @@ pub fn run() { session_commands::get_session_messages_since, session_commands::get_session_acp_metadata_messages, session_commands::get_session_acp_initialization, - session_commands::respond_acp_permission, session_commands::count_assistant_messages_after, session_commands::start_session, session_commands::resume_session, diff --git a/apps/staged/src-tauri/src/session_commands.rs b/apps/staged/src-tauri/src/session_commands.rs index 2798f450b..fe0e6a0d3 100644 --- a/apps/staged/src-tauri/src/session_commands.rs +++ b/apps/staged/src-tauri/src/session_commands.rs @@ -25,7 +25,7 @@ use tauri::path::BaseDirectory; use tauri::Manager; use crate::actions::{ActionExecutor, ActionRegistry}; -use crate::agent::{self, AcpProviderInfo, PermissionDecision, PermissionRegistry}; +use crate::agent::{self, AcpProviderInfo}; use crate::blox; use crate::git; use crate::session_runner::{self, SessionConfig}; @@ -376,19 +376,6 @@ pub fn get_session_acp_initialization( .map_err(|e| e.to_string()) } -#[tauri::command] -pub fn respond_acp_permission( - registry: tauri::State<'_, Arc>, - request_id: String, - option_id: Option, -) -> Result<(), String> { - let decision = match option_id { - Some(option_id) => PermissionDecision::Selected { option_id }, - None => PermissionDecision::Cancelled, - }; - registry.respond(&request_id, decision) -} - #[tauri::command] pub fn count_assistant_messages_after( store: tauri::State<'_, Mutex>>>, diff --git a/apps/staged/src-tauri/src/session_runner.rs b/apps/staged/src-tauri/src/session_runner.rs index 276dc16e7..0f91b200c 100644 --- a/apps/staged/src-tauri/src/session_runner.rs +++ b/apps/staged/src-tauri/src/session_runner.rs @@ -41,14 +41,14 @@ use std::sync::{Arc, OnceLock}; use std::time::Duration; use serde::{Deserialize, Serialize}; -use tauri::{AppHandle, Manager}; +use tauri::AppHandle; use tokio::io::{AsyncRead, AsyncReadExt}; use tokio_util::sync::CancellationToken; use acp_client::{AgentRunOutcome, McpServer, McpServerHttp}; use crate::actions::{ActionExecutor, ActionRegistry}; -use crate::agent::{AcpDriver, AgentDriver, MessageWriter, PermissionRegistry}; +use crate::agent::{AcpDriver, AgentDriver, MessageWriter}; use crate::git::Span; use crate::shell_env::ShellEnvCache; use crate::store::{ @@ -362,8 +362,6 @@ pub fn start_session( app_handle: AppHandle, registry: Arc, ) -> Result<(), String> { - let permission_registry = Arc::clone(&app_handle.state::>()); - // Create the driver eagerly so we fail fast if the agent isn't found. // Local sessions without an explicit provider resolve the first available // provider and persist it on the session. Review-producing callers resolve @@ -619,10 +617,10 @@ pub fn start_session( let mut include_images = true; loop { - let writer = Arc::new( - MessageWriter::new(config.session_id.clone(), Arc::clone(&store)) - .with_permission_registry(Arc::clone(&permission_registry)), - ); + let writer = Arc::new(MessageWriter::new( + config.session_id.clone(), + Arc::clone(&store), + )); let store_trait: Arc = store.clone(); let writer_trait: Arc = writer; let images = if include_images { diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 7aacb57e3..5c974edc0 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -44,7 +44,6 @@ use tower_http::cors::CorsLayer; use tower_http::services::ServeDir; use crate::actions::{ActionExecutor, ActionRegistry}; -use crate::agent::{PermissionDecision, PermissionRegistry}; use crate::pr_poll_scheduler::PrPollScheduler; use crate::session_commands; use crate::session_runner::{self, SessionConfig, SessionRegistry}; @@ -449,8 +448,6 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result>> = &app_handle.state::>>>(); let session_registry: &Arc = &app_handle.state::>(); - let permission_registry: &Arc = - &app_handle.state::>(); let action_executor: &Arc = &app_handle.state::>(); let action_registry: &Arc = &app_handle.state::>(); let pr_scheduler: &Arc = &app_handle.state::>(); @@ -2809,16 +2806,6 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result { - let request_id: String = arg(&args, "requestId")?; - let option_id: Option = opt_arg(&args, "optionId")?; - let decision = match option_id { - Some(option_id) => PermissionDecision::Selected { option_id }, - None => PermissionDecision::Cancelled, - }; - permission_registry.respond(&request_id, decision)?; - Ok(Value::Null) - } "start_session" => { let store = get_store(store_mutex)?; let prompt: String = arg(&args, "prompt")?; diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index 0945ef83d..ace96f378 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -733,10 +733,6 @@ export function getSessionAcpMetadataMessages(sessionId: string): Promise { - return invokeCommand('respond_acp_permission', { requestId, optionId }); -} - 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 1160cf37f..dbf1b134f 100644 --- a/apps/staged/src/lib/features/sessions/SessionModal.svelte +++ b/apps/staged/src/lib/features/sessions/SessionModal.svelte @@ -61,7 +61,6 @@ getSessionMessages, getSessionMessagesSince, handleExternalLinkClick, - respondAcpPermission, resumeSession, } from '../../api/commands'; import HashtagInput from './HashtagInput.svelte'; @@ -86,10 +85,6 @@ displayLocations, formatJson, latestAvailableCommands, - permissionOptions, - permissionRequestId, - permissionStatus, - permissionToolTitle, simpleUnifiedDiff, terminalRefsFromAcpContent, toolResultText, @@ -182,7 +177,6 @@ let messageQueue = $state([]); let copiedId = $state(null); let expandedTools = $state>(new Set()); - let permissionSubmitting = $state>(new Set()); let displayRoots = $state([]); let currentDisplayRootKey = ''; @@ -1107,33 +1101,6 @@ }); } - async function handlePermissionResponse(content: unknown, optionId: string | null) { - const requestId = permissionRequestId(content); - if (!requestId || permissionSubmitting.has(requestId)) return; - permissionSubmitting = new Set(permissionSubmitting).add(requestId); - try { - await respondAcpPermission(requestId, optionId); - acpMetadataMessages = await getSessionAcpMetadataMessages(sessionId); - } catch (e) { - error = `Failed to answer permission prompt: ${e instanceof Error ? e.message : String(e)}`; - } finally { - const next = new Set(permissionSubmitting); - next.delete(requestId); - permissionSubmitting = next; - } - } - - function isPermissionSubmitting(content: unknown): boolean { - const requestId = permissionRequestId(content); - return !!requestId && permissionSubmitting.has(requestId); - } - - function permissionOptionVariant(kind: string): 'outline' | 'destructive' | 'ghost' { - if (kind.startsWith('reject')) return 'destructive'; - if (kind.startsWith('allow')) return 'outline'; - return 'ghost'; - } - function acpEventSummary(event: AcpTranscriptEvent): string { if (event.kind === 'plan_update') { const entries = arrayProp(event.content, 'entries'); @@ -1143,11 +1110,6 @@ const commands = arrayProp(event.content, 'availableCommands'); return commands.length === 1 ? '1 command' : `${commands.length} commands`; } - if (event.kind === 'permission_request') { - return permissionStatus(event.content) === 'pending' - ? 'Waiting for response' - : sentenceCase(permissionStatus(event.content)); - } if (event.kind === 'config_options_update') { const options = Array.isArray(event.content) ? event.content : []; return options.length === 1 ? '1 option' : `${options.length} options`; @@ -1235,10 +1197,6 @@ return text.length > 72 ? `${text.slice(0, 72)}…` : text; } - function sentenceCase(value: string): string { - return value ? value.slice(0, 1).toUpperCase() + value.slice(1).replaceAll('_', ' ') : ''; - } - function groupKey(group: AcpTranscriptGroup): string { if (group.type === 'tools') return `t-${group.items[0].key}`; if (group.type === 'acp') return `a-${group.event.id}`; @@ -1573,39 +1531,6 @@ {event.title} {acpEventSummary(event)}
- {#if event.kind === 'permission_request'} - {@const status = permissionStatus(event.content)} - {@const requestId = permissionRequestId(event.content)} - {@const submitting = isPermissionSubmitting(event.content)} -
-
- {permissionToolTitle(event.content, displayRoots)} -
- {#if status === 'pending' && requestId} -
- {#each permissionOptions(event.content) as option} - - {/each} - -
- {/if} -
- {/if} {#if isExpanded}
{#if event.kind === 'plan_update'} @@ -2381,8 +2306,7 @@ font-weight: 500; } - .acp-event-body, - .permission-card-body { + .acp-event-body { margin-top: 4px; border: 1px solid var(--border-subtle); border-radius: 8px; @@ -2402,20 +2326,6 @@ line-height: 1.5; } - .permission-tool-title { - color: var(--text-primary); - font-size: var(--size-sm); - line-height: 1.4; - word-break: break-word; - } - - .permission-actions { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-top: 8px; - } - .plan-list, .config-list, .command-list { diff --git a/apps/staged/src/lib/features/sessions/acpTranscript.test.ts b/apps/staged/src/lib/features/sessions/acpTranscript.test.ts index 7069612ca..99eb7e9a1 100644 --- a/apps/staged/src/lib/features/sessions/acpTranscript.test.ts +++ b/apps/staged/src/lib/features/sessions/acpTranscript.test.ts @@ -75,7 +75,7 @@ describe('buildAcpTranscriptGroups', () => { } }); - it('uses permission responses to resolve pending permission request cards', () => { + it('does not surface permission metadata as transcript events', () => { const metadata = [ message({ id: 1, @@ -100,14 +100,7 @@ describe('buildAcpTranscriptGroups', () => { ]; const groups = buildAcpTranscriptGroups([], metadata); - expect(groups).toHaveLength(1); - expect(groups[0].type).toBe('acp'); - if (groups[0].type === 'acp') { - expect(groups[0].event.content).toMatchObject({ - requestId: 'perm-1', - status: 'selected', - }); - } + expect(groups).toEqual([]); }); }); diff --git a/apps/staged/src/lib/features/sessions/acpTranscript.ts b/apps/staged/src/lib/features/sessions/acpTranscript.ts index 5949da8c3..b53794a13 100644 --- a/apps/staged/src/lib/features/sessions/acpTranscript.ts +++ b/apps/staged/src/lib/features/sessions/acpTranscript.ts @@ -29,8 +29,7 @@ export type AcpTranscriptEventKind = | 'session_mode_state' | 'current_mode_update' | 'available_commands_update' - | 'session_info_update' - | 'permission_request'; + | 'session_info_update'; export interface AcpTranscriptEvent { id: number; @@ -83,7 +82,6 @@ const STANDALONE_EVENT_KINDS = new Set([ 'current_mode_update', 'available_commands_update', 'session_info_update', - 'permission_request', ]); export function buildAcpTranscriptGroups( @@ -405,27 +403,15 @@ function buildTimelineEntries( } function standaloneEvents(metadataRows: SessionMessage[]): AcpTranscriptEvent[] { - const permissionResponses = new Map(); - for (const row of metadataRows) { - if (row.acpEventKind !== 'permission_response') continue; - const requestId = stringProp(row.acpContent, 'requestId'); - if (requestId) permissionResponses.set(requestId, row.acpContent); - } - const events: AcpTranscriptEvent[] = []; for (const row of metadataRows) { const kind = row.acpEventKind as AcpTranscriptEventKind | undefined; if (!kind || !STANDALONE_EVENT_KINDS.has(kind)) continue; - let content = eventContent(row); - if (kind === 'permission_request') { - const requestId = stringProp(row.acpContent, 'requestId'); - content = (requestId && permissionResponses.get(requestId)) || row.acpContent; - } events.push({ id: row.id, kind, title: eventTitle(kind), - content, + content: eventContent(row), message: row, }); } @@ -464,8 +450,6 @@ function eventTitle(kind: AcpTranscriptEventKind): string { return 'Slash commands'; case 'session_info_update': return 'Session info'; - case 'permission_request': - return 'Permission'; } } @@ -567,37 +551,6 @@ function stringProp(value: unknown, key: string): string | null { return typeof prop === 'string' ? prop : null; } -export function permissionStatus(content: unknown): string { - return stringProp(content, 'status') ?? 'pending'; -} - -export function permissionRequestId(content: unknown): string | null { - return stringProp(content, 'requestId'); -} - -export function permissionOptions( - content: unknown -): { optionId: string; name: string; kind: string }[] { - const options = ( - content && typeof content === 'object' ? (content as Record).options : null - ) as unknown; - if (!Array.isArray(options)) return []; - return options - .map((option) => { - const optionId = stringProp(option, 'optionId'); - const name = stringProp(option, 'name'); - const kind = stringProp(option, 'kind') ?? ''; - return optionId && name ? { optionId, name, kind } : null; - }) - .filter( - (option): option is { optionId: string; name: string; kind: string } => option !== null - ); -} - -export function permissionToolTitle(content: unknown, displayRoots?: DisplayRootInput): string { - return makePathsRelative(stringProp(content, 'toolTitle') ?? '', displayRoots); -} - export function displayLocations(locations: unknown, displayRoots?: DisplayRootInput): string[] { if (!Array.isArray(locations)) return []; return locations diff --git a/crates/acp-client/src/driver.rs b/crates/acp-client/src/driver.rs index 584452bdd..34de8a32c 100644 --- a/crates/acp-client/src/driver.rs +++ b/crates/acp-client/src/driver.rs @@ -112,8 +112,8 @@ pub trait MessageWriter: Send + Sync { /// Ask the client UI to resolve an ACP permission request. /// - /// Implementations that cannot prompt should keep the legacy behavior by - /// selecting the first option unless the prompt turn has been cancelled. + /// Implementations that cannot prompt should automatically approve the + /// request unless the prompt turn has been cancelled. async fn request_permission( &self, request: AcpPermissionRequest, @@ -122,19 +122,38 @@ pub trait MessageWriter: Send + Sync { if cancel_token.is_cancelled() { AcpPermissionDecision::Cancelled } else { - request - .options - .first() - .map(|option| AcpPermissionDecision::Selected { - option_id: option.option_id.clone(), - }) - .unwrap_or(AcpPermissionDecision::Selected { - option_id: "approve".to_string(), - }) + autoapprove_permission_decision(&request) } } } +fn permission_option_is_approval(option: &AcpPermissionOption) -> bool { + let kind = option.kind.to_ascii_lowercase(); + let option_id = option.option_id.to_ascii_lowercase(); + let name = option.name.to_ascii_lowercase(); + + kind.starts_with("allow") + || kind.starts_with("approve") + || option_id.starts_with("allow") + || option_id.starts_with("approve") + || name.contains("allow") + || name.contains("approve") +} + +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, @@ -1840,6 +1859,17 @@ impl AcpNotificationHandler { } } +#[cfg(test)] +fn schema_permission_option_is_approval( + option: &agent_client_protocol::schema::v1::PermissionOption, +) -> bool { + matches!( + option.kind, + agent_client_protocol::schema::v1::PermissionOptionKind::AllowOnce + | agent_client_protocol::schema::v1::PermissionOptionKind::AllowAlways + ) +} + #[cfg(test)] fn permission_decision_for_options( options: &[agent_client_protocol::schema::v1::PermissionOption], @@ -1850,7 +1880,9 @@ fn permission_decision_for_options( } let option_id = options - .first() + .iter() + .find(|option| schema_permission_option_is_approval(option)) + .or_else(|| options.first()) .map(|opt| opt.option_id.clone()) .unwrap_or_else(|| PermissionOptionId::new("approve")); @@ -3065,12 +3097,11 @@ mod tests { } #[test] - fn permission_response_selects_first_option_before_cancellation() { - let options = vec![PermissionOption::new( - "approve", - "Approve", - PermissionOptionKind::AllowOnce, - )]; + 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); From bc5ad227c14b849de65c9825df61028c680dfacd Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 3 Jul 2026 13:54:29 +1000 Subject: [PATCH 07/15] fix(acp): respect permission option kind for autoapproval Keep ACP permission option kinds typed in the wrapper and centralize the autoapproval helper so reject kinds are never selected just because their label contains allow. Update Staged to reuse the shared helper and add regressions for Don't allow and Disallow reject options. Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/agent/writer.rs | 39 +--- .../staged/src-tauri/src/pikchr_subsession.rs | 4 +- crates/acp-client/src/driver.rs | 202 ++++++++++++++---- crates/acp-client/src/lib.rs | 7 +- 4 files changed, 176 insertions(+), 76 deletions(-) diff --git a/apps/staged/src-tauri/src/agent/writer.rs b/apps/staged/src-tauri/src/agent/writer.rs index 2ceca82af..1fffd3922 100644 --- a/apps/staged/src-tauri/src/agent/writer.rs +++ b/apps/staged/src-tauri/src/agent/writer.rs @@ -19,8 +19,8 @@ use tokio_util::sync::CancellationToken; use crate::store::{AcpMessageMetadata, MessageRole, Store}; use acp_client::{ - strip_code_fences, AcpEventMetadata, AcpInitializeMetadata, AcpPermissionDecision, - AcpPermissionOption, AcpPermissionRequest, AcpToolCallMetadata, + autoapprove_permission_decision, strip_code_fences, AcpEventMetadata, AcpInitializeMetadata, + AcpPermissionDecision, AcpPermissionRequest, AcpToolCallMetadata, }; /// Minimum interval between DB flushes for streaming text. Chunks accumulate @@ -100,33 +100,6 @@ fn acp_event_role(metadata: &AcpMessageMetadata) -> MessageRole { } } -fn permission_option_is_approval(option: &AcpPermissionOption) -> bool { - let kind = option.kind.to_ascii_lowercase(); - let option_id = option.option_id.to_ascii_lowercase(); - let name = option.name.to_ascii_lowercase(); - - kind.starts_with("allow") - || kind.starts_with("approve") - || option_id.starts_with("allow") - || option_id.starts_with("approve") - || name.contains("allow") - || name.contains("approve") -} - -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(), - }) -} - impl MessageWriter { pub fn new(session_id: String, store: Arc) -> Self { Self { @@ -443,7 +416,9 @@ mod tests { use super::MessageWriter; use crate::store::{MessageRole, Session, Store}; - use acp_client::{AcpPermissionDecision, AcpPermissionOption, AcpPermissionRequest}; + use acp_client::{ + AcpPermissionDecision, AcpPermissionOption, AcpPermissionOptionKind, AcpPermissionRequest, + }; use tokio_util::sync::CancellationToken; fn setup_writer() -> (Arc, String, MessageWriter) { @@ -470,12 +445,12 @@ mod tests { AcpPermissionOption { option_id: "allow-once".to_string(), name: "Allow once".to_string(), - kind: "allow_once".to_string(), + kind: AcpPermissionOptionKind::AllowOnce, }, AcpPermissionOption { option_id: "reject-once".to_string(), name: "Deny".to_string(), - kind: "reject_once".to_string(), + kind: AcpPermissionOptionKind::RejectOnce, }, ], raw_request: None, diff --git a/apps/staged/src-tauri/src/pikchr_subsession.rs b/apps/staged/src-tauri/src/pikchr_subsession.rs index cf4a91832..481a6a383 100644 --- a/apps/staged/src-tauri/src/pikchr_subsession.rs +++ b/apps/staged/src-tauri/src/pikchr_subsession.rs @@ -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/crates/acp-client/src/driver.rs b/crates/acp-client/src/driver.rs index 34de8a32c..bad043db7 100644 --- a/crates/acp-client/src/driver.rs +++ b/crates/acp-client/src/driver.rs @@ -22,7 +22,8 @@ use agent_client_protocol::{ AgentCapabilities, AuthMethod, AuthenticateRequest, CancelNotification, ContentBlock as AcpContentBlock, ContentChunk, ImageContent, Implementation, InitializeRequest, InitializeResponse, LoadSessionRequest, McpCapabilities, McpServer, - NewSessionRequest, PermissionOptionId, PromptRequest, PromptResponse, + NewSessionRequest, PermissionOption as SchemaPermissionOption, PermissionOptionId, + PermissionOptionKind as SchemaPermissionOptionKind, PromptRequest, PromptResponse, RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, SelectedPermissionOutcome, SessionConfigOption, SessionInfoUpdate, SessionModeState, SessionNotification, SessionUpdate, StopReason, TextContent, ToolCallContent, @@ -128,19 +129,23 @@ pub trait MessageWriter: Send + Sync { } fn permission_option_is_approval(option: &AcpPermissionOption) -> bool { - let kind = option.kind.to_ascii_lowercase(); + 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(); - kind.starts_with("allow") - || kind.starts_with("approve") - || option_id.starts_with("allow") + option_id.starts_with("allow") || option_id.starts_with("approve") || name.contains("allow") || name.contains("approve") } -fn autoapprove_permission_decision(request: &AcpPermissionRequest) -> AcpPermissionDecision { +pub fn autoapprove_permission_decision(request: &AcpPermissionRequest) -> AcpPermissionDecision { request .options .iter() @@ -214,7 +219,38 @@ pub struct AcpPermissionRequest { pub struct AcpPermissionOption { pub option_id: String, pub name: String, - pub kind: 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)] @@ -1859,36 +1895,34 @@ impl AcpNotificationHandler { } } -#[cfg(test)] -fn schema_permission_option_is_approval( - option: &agent_client_protocol::schema::v1::PermissionOption, -) -> bool { - matches!( - option.kind, - agent_client_protocol::schema::v1::PermissionOptionKind::AllowOnce - | agent_client_protocol::schema::v1::PermissionOptionKind::AllowAlways - ) -} - #[cfg(test)] fn permission_decision_for_options( - options: &[agent_client_protocol::schema::v1::PermissionOption], + options: &[SchemaPermissionOption], cancelled: bool, ) -> AcpPermissionDecision { if cancelled { return AcpPermissionDecision::Cancelled; } - let option_id = options - .iter() - .find(|option| schema_permission_option_is_approval(option)) - .or_else(|| options.first()) - .map(|opt| opt.option_id.clone()) - .unwrap_or_else(|| PermissionOptionId::new("approve")); + 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, + }; - AcpPermissionDecision::Selected { - option_id: option_id.0.as_ref().to_string(), - } + autoapprove_permission_decision(&request) } #[cfg(test)] @@ -1912,6 +1946,14 @@ fn permission_response_for_decision(decision: AcpPermissionDecision) -> RequestP } } +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(); @@ -1941,11 +1983,7 @@ fn acp_permission_request_from_args(args: &RequestPermissionRequest) -> AcpPermi options: args .options .iter() - .map(|option| AcpPermissionOption { - option_id: option.option_id.0.as_ref().to_string(), - name: option.name.clone(), - kind: serialize_as_string(&option.kind).unwrap_or_else(|| "unknown".to_string()), - }) + .map(acp_permission_option_from_schema) .collect(), raw_request: serialize_value(args), } @@ -2480,12 +2518,14 @@ impl MessageWriter for BasicMessageWriter { #[cfg(test)] mod tests { use super::{ - acp_spawn_command, 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, AgentRunOutcome, RemoteLineOutcome, ReplayBoundary, ReplayBuffer, ReplayEvent, + 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, AcpPermissionOption, AcpPermissionOptionKind, + AcpPermissionRequest, AgentRunOutcome, RemoteLineOutcome, ReplayBoundary, ReplayBuffer, + ReplayEvent, }; use agent_client_protocol::schema::v1::{ ContentBlock as AcpContentBlock, McpCapabilities, McpServer, McpServerHttp, McpServerSse, @@ -2528,6 +2568,35 @@ 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, + } + } + #[test] fn consumes_wrapped_json_line_across_multiple_chunks() { let mut pending = String::new(); @@ -3114,6 +3183,61 @@ mod tests { } } + #[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!( diff --git a/crates/acp-client/src/lib.rs b/crates/acp-client/src/lib.rs index 20608d937..2e86528a2 100644 --- a/crates/acp-client/src/lib.rs +++ b/crates/acp-client/src/lib.rs @@ -27,9 +27,10 @@ pub use agent_client_protocol::schema::v1::{ SessionConfigOptionCategory, SessionInfoUpdate, SessionModeState as SessionModelState, }; pub use driver::{ - strip_code_fences, AcpDriver, AcpEventMetadata, AcpInitializeMetadata, AcpPermissionDecision, - AcpPermissionOption, AcpPermissionRequest, AcpToolCallMetadata, AgentDriver, AgentRunOutcome, - BasicMessageWriter, MessageWriter, ReplayBoundary, 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::{ From 840f3916cc1a9180bd4c2261bf42e7b92028916c Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 6 Jul 2026 14:04:51 +1000 Subject: [PATCH 08/15] fix(acp): hide chat metadata rows Hide usage, mode, configuration, and slash command ACP metadata rows from the chat transcript while keeping plan metadata visible. Remove the right-aligned status label from chat tool call headers; status remains indicated by the icon and expanded details. Signed-off-by: Matt Toohey --- .../lib/features/sessions/SessionModal.svelte | 8 --- .../features/sessions/acpTranscript.test.ts | 61 +++++++++++++++++++ .../lib/features/sessions/acpTranscript.ts | 10 +-- 3 files changed, 63 insertions(+), 16 deletions(-) diff --git a/apps/staged/src/lib/features/sessions/SessionModal.svelte b/apps/staged/src/lib/features/sessions/SessionModal.svelte index dbf1b134f..43bc721b6 100644 --- a/apps/staged/src/lib/features/sessions/SessionModal.svelte +++ b/apps/staged/src/lib/features/sessions/SessionModal.svelte @@ -1461,7 +1461,6 @@ {#if item.detail} {item.detail} {/if} - {item.statusLabel}
{#if isExpanded && hasDetails}
{ 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('latestAvailableCommands', () => { diff --git a/apps/staged/src/lib/features/sessions/acpTranscript.ts b/apps/staged/src/lib/features/sessions/acpTranscript.ts index b53794a13..c1099f35b 100644 --- a/apps/staged/src/lib/features/sessions/acpTranscript.ts +++ b/apps/staged/src/lib/features/sessions/acpTranscript.ts @@ -73,14 +73,8 @@ interface TimelineEntry { } const TOOL_EVENT_KINDS = new Set(['tool_call', 'tool_call_update']); -const STANDALONE_EVENT_KINDS = new Set([ +const VISIBLE_STANDALONE_EVENT_KINDS = new Set([ 'plan_update', - 'usage_update', - 'prompt_response', - 'config_options_update', - 'session_mode_state', - 'current_mode_update', - 'available_commands_update', 'session_info_update', ]); @@ -406,7 +400,7 @@ function standaloneEvents(metadataRows: SessionMessage[]): AcpTranscriptEvent[] const events: AcpTranscriptEvent[] = []; for (const row of metadataRows) { const kind = row.acpEventKind as AcpTranscriptEventKind | undefined; - if (!kind || !STANDALONE_EVENT_KINDS.has(kind)) continue; + if (!kind || !VISIBLE_STANDALONE_EVENT_KINDS.has(kind)) continue; events.push({ id: row.id, kind, From d1c74f438c716e18d505df2b0446eec688aa1f44 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 6 Jul 2026 14:50:48 +1000 Subject: [PATCH 09/15] fix(acp): restore verb-grouped tool cards Signed-off-by: Matt Toohey --- .../lib/features/sessions/SessionModal.svelte | 245 ++++++++++-------- .../features/sessions/acpTranscript.test.ts | 135 +++++++++- .../lib/features/sessions/acpTranscript.ts | 45 +++- .../features/sessions/sessionModalHelpers.ts | 2 +- 4 files changed, 323 insertions(+), 104 deletions(-) diff --git a/apps/staged/src/lib/features/sessions/SessionModal.svelte b/apps/staged/src/lib/features/sessions/SessionModal.svelte index 43bc721b6..a475fc477 100644 --- a/apps/staged/src/lib/features/sessions/SessionModal.svelte +++ b/apps/staged/src/lib/features/sessions/SessionModal.svelte @@ -84,6 +84,7 @@ diffsFromAcpContent, displayLocations, formatJson, + groupRichToolsByVerb, latestAvailableCommands, simpleUnifiedDiff, terminalRefsFromAcpContent, @@ -91,6 +92,7 @@ type AcpCommand, type AcpTranscriptEvent, type AcpTranscriptGroup, + type RichToolItem, } from './acpTranscript'; import { displayRootKey, @@ -1212,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 if group.type === 'tools'}
- {#each group.items as item (item.key)} - {@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)} - > - - - {#if item.statusTone === 'running'} - - {:else if item.statusTone === 'success'} - - {:else if item.statusTone === 'danger'} - - {:else if item.statusTone === 'cancelled'} - - {:else} - - {/if} - - {item.verb} - {#if item.detail} - {item.detail} - {/if} -
- {#if isExpanded && hasDetails} + {#each groupRichToolsByVerb(group.items) as verbGroup (verbGroup.key)} + {#if verbGroup.items.length === 1} + {@render richToolCard(verbGroup.items[0], false)} + {:else} + {@const isGroupExpanded = expandedTools.has(verbGroup.key)} +
+ +
toggleTool(verbGroup.key)} > - {#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} -
+ + {@render toolStatusIcon(verbGroup.statusTone)} + + {verbGroup.verb} + {verbGroup.summary} +
+
+ {#if isGroupExpanded} +
+ {#each verbGroup.items as item (item.key)} + {@render richToolCard(item, true)} + {/each}
{/if} -
+ {/if} {/each}
{:else} @@ -2080,6 +2119,10 @@ min-width: 0; } + .tool-card-nested { + padding-left: 16px; + } + .tool-header { 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 index 6a1dac865..66876aed8 100644 --- a/apps/staged/src/lib/features/sessions/acpTranscript.test.ts +++ b/apps/staged/src/lib/features/sessions/acpTranscript.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; import type { SessionMessage } from '../../types'; -import { buildAcpTranscriptGroups, latestAvailableCommands } from './acpTranscript'; +import { + buildAcpTranscriptGroups, + groupRichToolsByVerb, + latestAvailableCommands, + type RichToolItem, +} from './acpTranscript'; function message( overrides: Partial & Pick @@ -165,6 +170,114 @@ describe('buildAcpTranscriptGroups', () => { }); }); +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([ @@ -193,3 +306,23 @@ describe('latestAvailableCommands', () => { ]); }); }); + +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 index c1099f35b..7e1782313 100644 --- a/apps/staged/src/lib/features/sessions/acpTranscript.ts +++ b/apps/staged/src/lib/features/sessions/acpTranscript.ts @@ -1,6 +1,11 @@ import type { SessionMessage } from '../../types'; import type { DisplayRootInput } from './pathDisplayRoots'; -import { formatToolDisplay, makePathsRelative, stripCodeFences } from './sessionModalHelpers'; +import { + formatToolDisplay, + makePathsRelative, + stripCodeFences, + verbGroupSummary, +} from './sessionModalHelpers'; export type ToolStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'cancelled'; @@ -21,6 +26,14 @@ export interface RichToolItem { locations: unknown; } +export interface RichToolVerbGroup { + key: string; + verb: string; + summary: string; + statusTone: RichToolItem['statusTone']; + items: RichToolItem[]; +} + export type AcpTranscriptEventKind = | 'plan_update' | 'usage_update' @@ -215,6 +228,28 @@ export function terminalRefsFromAcpContent(content: unknown): string[] { .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'); @@ -238,6 +273,14 @@ export function simpleUnifiedDiff(diff: AcpDiff): string { 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); 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}`; } From 7ce3e662271e465ee301e71ea08fa2825f3894db Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 6 Jul 2026 20:07:07 +1000 Subject: [PATCH 10/15] fix(acp): keep tool results keyed by call id Carry the ACP tool call id through result persistence so interleaved ToolCallUpdate content updates separate transcript rows. Persist the tool call id on result rows for structured replay boundaries and add a regression for alternating tool result updates. Signed-off-by: Matt Toohey --- .../src-tauri/examples/acp_stream_probe.rs | 5 +- apps/staged/src-tauri/src/agent/writer.rs | 66 +++++++++++++++---- .../staged/src-tauri/src/pikchr_subsession.rs | 2 +- crates/acp-client/src/driver.rs | 6 +- 4 files changed, 59 insertions(+), 20 deletions(-) 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/writer.rs b/apps/staged/src-tauri/src/agent/writer.rs index 1fffd3922..9941b60af 100644 --- a/apps/staged/src-tauri/src/agent/writer.rs +++ b/apps/staged/src-tauri/src/agent/writer.rs @@ -45,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. @@ -109,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()), } } @@ -152,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); @@ -203,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; } @@ -216,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}"), } @@ -295,8 +301,8 @@ 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) { @@ -528,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) @@ -538,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] diff --git a/apps/staged/src-tauri/src/pikchr_subsession.rs b/apps/staged/src-tauri/src/pikchr_subsession.rs index 481a6a383..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)] diff --git a/crates/acp-client/src/driver.rs b/crates/acp-client/src/driver.rs index bad043db7..0d0dc012d 100644 --- a/crates/acp-client/src/driver.rs +++ b/crates/acp-client/src/driver.rs @@ -84,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.). /// @@ -1883,7 +1883,7 @@ impl AcpNotificationHandler { } 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 => { @@ -2509,7 +2509,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)); } From acdd0b7a96d5844fa574123426de3bb463664c56 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 6 Jul 2026 20:10:33 +1000 Subject: [PATCH 11/15] fix(acp): skip replay wait without boundary Short-circuit ACP resume replay when persisted history has no non-user replay target, while still transitioning the handler before the next prompt. Add a regression for user-only replay boundaries. Signed-off-by: Matt Toohey --- crates/acp-client/src/driver.rs | 36 +++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/crates/acp-client/src/driver.rs b/crates/acp-client/src/driver.rs index 0d0dc012d..cd6d44192 100644 --- a/crates/acp-client/src/driver.rs +++ b/crates/acp-client/src/driver.rs @@ -1547,6 +1547,14 @@ impl AcpNotificationHandler { 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, + } + } } impl AcpNotificationHandler { @@ -2089,7 +2097,7 @@ async fn run_acp_protocol( // 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! { @@ -2114,6 +2122,8 @@ async fn run_acp_protocol( } } } + } + if acp_session_id.is_some() { handler.transition_to_waiting_for_prompt().await; } @@ -2523,9 +2533,9 @@ mod tests { 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, AcpPermissionOption, AcpPermissionOptionKind, - AcpPermissionRequest, AgentRunOutcome, RemoteLineOutcome, ReplayBoundary, ReplayBuffer, - ReplayEvent, + shell_exec_line, shell_quote, AcpDriver, AcpNotificationHandler, AcpPermissionOption, + AcpPermissionOptionKind, AcpPermissionRequest, AgentRunOutcome, BasicMessageWriter, + MessageWriter, RemoteLineOutcome, ReplayBoundary, ReplayBuffer, ReplayEvent, }; use agent_client_protocol::schema::v1::{ ContentBlock as AcpContentBlock, McpCapabilities, McpServer, McpServerHttp, McpServerSse, @@ -2534,7 +2544,9 @@ mod tests { }; use std::ffi::OsString; use std::path::{Path, PathBuf}; + use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; + use tokio_util::sync::CancellationToken; fn unique_test_dir(prefix: &str) -> PathBuf { let nonce = SystemTime::now() @@ -3123,6 +3135,22 @@ mod tests { assert_eq!(buffer.match_cursor, 1); } + #[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); + } + #[test] fn replay_boundary_falls_back_to_role_and_content_without_ids() { let mut buffer = ReplayBuffer::new(vec![ReplayBoundary::legacy( From 296e6cf322ef5c7484d94593bef4f006e68183a0 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 6 Jul 2026 20:18:53 +1000 Subject: [PATCH 12/15] fix(acp): match replay projections by row id Use ACP message IDs when visible projection rows have them, and otherwise consume concrete visible row occurrences instead of dropping every matching role/content pair. This preserves repeated user or assistant messages as replay boundaries while still avoiding duplicate chunk-backed projections. Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/agent/mod.rs | 243 ++++++++++++++++++++----- 1 file changed, 194 insertions(+), 49 deletions(-) diff --git a/apps/staged/src-tauri/src/agent/mod.rs b/apps/staged/src-tauri/src/agent/mod.rs index 87b5a83f2..7ee455d0f 100644 --- a/apps/staged/src-tauri/src/agent/mod.rs +++ b/apps/staged/src-tauri/src/agent/mod.rs @@ -44,11 +44,15 @@ pub use writer::MessageWriter; fn replay_boundaries_from_messages( messages: Vec, ) -> Vec { - let acp_message_chunk_keys = acp_message_chunk_content_keys(&messages); + 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(); @@ -67,16 +71,6 @@ fn replay_boundaries_from_messages( continue; } - let is_visible_assistant_or_user = - matches!(message.role, MessageRole::Assistant | MessageRole::User) - && !is_hidden_acp_metadata(&message); - let is_duplicate_acp_message_projection = is_visible_assistant_or_user - && acp_message_chunk_keys - .contains(&(message.role.as_str().to_string(), message.content.clone())); - if is_duplicate_acp_message_projection { - continue; - } - if is_hidden_acp_metadata(&message) { continue; } @@ -92,25 +86,118 @@ fn replay_boundaries_from_messages( boundaries } -fn acp_message_chunk_content_keys(messages: &[SessionMessage]) -> HashSet<(String, String)> { - let mut content_by_id: HashMap = HashMap::new(); +#[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 entry = content_by_id - .entry(message_id) - .or_insert_with(|| (message.role.as_str().to_string(), String::new())); - entry - .1 - .push_str(&acp_chunk_text(message).unwrap_or_default()); - } - - content_by_id - .into_values() - .filter(|(_, content)| !content.is_empty()) - .collect() + 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 preceding.is_none_or(|current| message.id > current.id) { + preceding = Some(message); + } + } else if message.id <= projection.last_chunk_row_id { + if inside_span.is_none_or(|current| message.id < current.id) { + inside_span = Some(message); + } + } else if following.is_none_or(|current| message.id < current.id) { + 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 { @@ -157,6 +244,17 @@ mod tests { } } + 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![ @@ -165,27 +263,13 @@ mod tests { 2, MessageRole::Assistant, "", - AcpMessageMetadata { - acp_event_kind: Some("agent_message_chunk".to_string()), - acp_message_id: Some("msg-1".to_string()), - acp_content: Some(serde_json::json!({ - "content": {"type": "text", "text": "hello "} - })), - ..Default::default() - }, + acp_text_chunk("agent_message_chunk", "msg-1", "hello "), ), message( 3, MessageRole::Assistant, "", - AcpMessageMetadata { - acp_event_kind: Some("agent_message_chunk".to_string()), - acp_message_id: Some("msg-1".to_string()), - acp_content: Some(serde_json::json!({ - "content": {"type": "text", "text": "world"} - })), - ..Default::default() - }, + acp_text_chunk("agent_message_chunk", "msg-1", "world"), ), message( 4, @@ -206,6 +290,74 @@ mod tests { 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( @@ -234,14 +386,7 @@ mod tests { 2, MessageRole::Assistant, "", - AcpMessageMetadata { - acp_event_kind: Some("agent_message_chunk".to_string()), - acp_message_id: Some("msg-2".to_string()), - acp_content: Some(serde_json::json!({ - "content": {"type": "text", "text": "new assistant text"} - })), - ..Default::default() - }, + acp_text_chunk("agent_message_chunk", "msg-2", "new assistant text"), ), ]); From 523f75c7a2d8210a5811cd3e43d59fddd32c3db1 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 6 Jul 2026 21:42:49 +1000 Subject: [PATCH 13/15] fix(acp): satisfy push hook lints --- apps/staged/src-tauri/src/agent/mod.rs | 15 +++++++-- crates/acp-client/src/driver.rs | 43 ++++++++++++++++---------- 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/apps/staged/src-tauri/src/agent/mod.rs b/apps/staged/src-tauri/src/agent/mod.rs index 7ee455d0f..ab3b7ac23 100644 --- a/apps/staged/src-tauri/src/agent/mod.rs +++ b/apps/staged/src-tauri/src/agent/mod.rs @@ -177,14 +177,23 @@ fn find_visible_projection_duplicate_by_content( for message in candidates { if message.id < projection.first_chunk_row_id { - if preceding.is_none_or(|current| message.id > current.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 inside_span.is_none_or(|current| message.id < current.id) { + if match inside_span { + Some(current) => message.id < current.id, + None => true, + } { inside_span = Some(message); } - } else if following.is_none_or(|current| message.id < current.id) { + } else if match following { + Some(current) => message.id < current.id, + None => true, + } { following = Some(message); } } diff --git a/crates/acp-client/src/driver.rs b/crates/acp-client/src/driver.rs index cd6d44192..44f9a2928 100644 --- a/crates/acp-client/src/driver.rs +++ b/crates/acp-client/src/driver.rs @@ -2026,9 +2026,7 @@ fn usage_update_metadata(update: &T) -> AcpEventMetadata { fn prompt_response_metadata(response: &PromptResponse) -> Option { let usage = response.usage.as_ref().and_then(serialize_value); - if usage.is_none() { - return None; - } + usage.as_ref()?; Some(AcpEventMetadata { event_kind: Some("prompt_response".to_string()), @@ -2067,16 +2065,16 @@ async fn run_acp_protocol( ) -> 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, agent_label, - ), + }), ); let setup = tokio::select! { _ = cancel_token.cancelled() => { @@ -2192,16 +2190,29 @@ struct AcpSessionSetup { agent_capabilities: AgentCapabilities, } -async fn setup_acp_session( - connection: &ConnectionTo, - working_dir: &Path, - store: &Arc, - writer: &Arc, - our_session_id: &str, - acp_session_id: Option<&str>, - mcp_servers: &[McpServer], - agent_label: &str, -) -> Result { +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::V1).client_info(client_info); From edd92411190f5419c965677fc451928de4f3c0be Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 6 Jul 2026 22:32:07 +1000 Subject: [PATCH 14/15] fix(acp): split replay chunks by message id Signed-off-by: Matt Toohey --- crates/acp-client/src/driver.rs | 91 ++++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 25 deletions(-) diff --git a/crates/acp-client/src/driver.rs b/crates/acp-client/src/driver.rs index 44f9a2928..6ec952bf2 100644 --- a/crates/acp-client/src/driver.rs +++ b/crates/acp-client/src/driver.rs @@ -1408,6 +1408,27 @@ impl ReplayBuffer { 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() @@ -1667,37 +1688,22 @@ impl 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_message_id = - chunk.message_id.as_ref().map(|id| id.0.to_string()); - } else if buf.current_message_id.is_none() { - buf.current_message_id = - chunk.message_id.as_ref().map(|id| id.0.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_message_id = - chunk.message_id.as_ref().map(|id| id.0.to_string()); - } else if buf.current_message_id.is_none() { - buf.current_message_id = - chunk.message_id.as_ref().map(|id| id.0.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 } @@ -3146,6 +3152,41 @@ mod tests { 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()); From a906404dd35c5c030a836c53f495e36cc21155d8 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 6 Jul 2026 22:36:57 +1000 Subject: [PATCH 15/15] fix(acp): drop replayed plan updates Signed-off-by: Matt Toohey --- crates/acp-client/src/driver.rs | 112 +++++++++++++++++++++++++++----- 1 file changed, 95 insertions(+), 17 deletions(-) diff --git a/crates/acp-client/src/driver.rs b/crates/acp-client/src/driver.rs index 6ec952bf2..269c70436 100644 --- a/crates/acp-client/src/driver.rs +++ b/crates/acp-client/src/driver.rs @@ -1601,7 +1601,7 @@ impl AcpNotificationHandler { &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; @@ -1623,16 +1623,6 @@ impl AcpNotificationHandler { .await; return Ok(()); } - SessionUpdate::Plan(plan) => { - self.writer - .record_acp_event_metadata(AcpEventMetadata { - event_kind: Some("plan_update".to_string()), - content: serialize_value(plan), - ..Default::default() - }) - .await; - return Ok(()); - } SessionUpdate::AvailableCommandsUpdate(update) => { self.writer .record_acp_event_metadata(AcpEventMetadata { @@ -1856,6 +1846,11 @@ impl AcpNotificationHandler { 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, } } @@ -2550,18 +2545,19 @@ mod tests { 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, AcpNotificationHandler, AcpPermissionOption, - AcpPermissionOptionKind, AcpPermissionRequest, AgentRunOutcome, BasicMessageWriter, - MessageWriter, RemoteLineOutcome, ReplayBoundary, ReplayBuffer, ReplayEvent, + 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, RequestPermissionOutcome, - StopReason, + McpServerStdio, PermissionOption, PermissionOptionKind, Plan, PlanEntry, PlanEntryPriority, + PlanEntryStatus, RequestPermissionOutcome, SessionNotification, SessionUpdate, StopReason, }; use std::ffi::OsString; use std::path::{Path, PathBuf}; - use std::sync::Arc; + use std::sync::{Arc, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; use tokio_util::sync::CancellationToken; @@ -2626,6 +2622,48 @@ mod tests { } } + #[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(); @@ -3203,6 +3241,46 @@ mod tests { 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(