diff --git a/Cargo.lock b/Cargo.lock index 3c6b1d0424..9dd2886db8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,37 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "agent-client-protocol" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "499b7ff5c6c842e43fb188f6da7c99a258ae89a9df8c896d6e9784da9b4b23e7" +dependencies = [ + "agent-client-protocol-schema", + "anyhow", + "async-broadcast", + "async-trait", + "derive_more", + "futures", + "log", + "serde", + "serde_json", +] + +[[package]] +name = "agent-client-protocol-schema" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44bc1fef9c32f03bce2ab44af35b6f483bfd169bf55cc59beeb2e3b1a00ae4d1" +dependencies = [ + "anyhow", + "derive_more", + "schemars 1.2.1", + "serde", + "serde_json", + "strum 0.27.2", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -104,7 +135,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.60.2", "x11rb", ] @@ -145,11 +176,23 @@ dependencies = [ "serde_json", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-compression" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" dependencies = [ "compression-codecs", "compression-core", @@ -650,9 +693,9 @@ dependencies = [ [[package]] name = "axum" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", @@ -747,9 +790,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" dependencies = [ "serde_core", ] @@ -859,9 +902,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.60" +version = "1.2.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", "jobserver", @@ -1038,9 +1081,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.37" +version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" dependencies = [ "compression-core", "flate2", @@ -1049,9 +1092,18 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.31" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] [[package]] name = "config" @@ -1069,7 +1121,7 @@ dependencies = [ "serde_core", "serde_json", "toml 1.1.2+spec-1.1.0", - "winnow 1.0.1", + "winnow 1.0.2", "yaml-rust2", ] @@ -1323,7 +1375,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "crossterm_winapi", "mio", "parking_lot", @@ -1339,7 +1391,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "crossterm_winapi", "derive_more", "document-features", @@ -1530,9 +1582,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "deranged" @@ -1653,7 +1705,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b035a542cf7abf01f2e3c4d5a7acbaebfefe120ae4efc7bde3df98186e4b8af7" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "proc-macro2", "proc-macro2-diagnostics", "quote", @@ -1677,9 +1729,9 @@ dependencies = [ [[package]] name = "diesel_derives" -version = "2.3.7" +version = "2.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47618bf0fac06bb670c036e48404c26a865e6a71af4114dfd97dfe89936e404e" +checksum = "d1817b7f4279b947fc4cafddec12b0e5f8727141706561ce3ac94a60bddd1cf5" dependencies = [ "diesel_table_macro_syntax", "dsl_auto_type", @@ -1727,9 +1779,9 @@ dependencies = [ [[package]] name = "digest" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", "const-oid", @@ -1785,7 +1837,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "objc2", ] @@ -1993,6 +2045,27 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fake" version = "5.1.0" @@ -2023,23 +2096,9 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fax" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" -dependencies = [ - "fax_derive", -] - -[[package]] -name = "fax_derive" -version = "0.2.0" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" [[package]] name = "fd-lock" @@ -2186,10 +2245,12 @@ dependencies = [ name = "forge_app" version = "0.1.0" dependencies = [ + "agent-client-protocol", "anyhow", "async-recursion", "async-trait", "backon", + "base64 0.22.1", "bytes", "chrono", "console", @@ -2229,9 +2290,11 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-stream", + "tokio-util", "tonic", "tracing", "url", + "uuid", ] [[package]] @@ -3210,7 +3273,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b39ed39ee4c10a3b157f9fb94bac8098d9f8e56201f0cf7dee6c187416c4b2" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "bstr", "gix-path", "libc", @@ -3361,9 +3424,9 @@ dependencies = [ [[package]] name = "gix-fs" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b5d9f7e55a0f9a936a877fa4f9758692a308550a39a45684286941a20a8e5c0" +checksum = "1e1967daac9848757c47c2aef0c57bcadc1a897347f559778249bf286a536c86" dependencies = [ "bstr", "fastrand", @@ -3379,7 +3442,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08bf29249a069bf2507f5964f80997f37b134d320ea348d66527726b9be2c38c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "bstr", "gix-features", "gix-path", @@ -3437,7 +3500,7 @@ version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54c3ef97ad08121e4327a6226bd63fed6b9e3c6b976d48bddd4356d9d41191db" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "bstr", "filetime", "fnv", @@ -3514,7 +3577,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "103d42bfade1b8a96ca5005933127bdad461ce588d92422b2c2daa3ff20d780c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "gix-commitgraph", "gix-date", "gix-hash", @@ -3612,7 +3675,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a84a4f083dd70fb49f4377e13afa6d90df2daaa1c705c49d6ff1331fc7e8855" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "bstr", "gix-attributes", "gix-config-value", @@ -3706,7 +3769,7 @@ version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fb5288fac706d3ea3e4e2ba9ec38b78743b8c02f422e18cb342299cfd6ab7e8" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "bstr", "gix-commitgraph", "gix-date", @@ -3741,7 +3804,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5a3a2d3e504a238136751e646a6c028252286a0ea64ea9974bf0498633407c6" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "gix-path", "libc", "windows-sys 0.61.2", @@ -3841,7 +3904,7 @@ version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a14b7052c0786676c03e71fcfde7d7f0f8e8316e642b5cec6bb3998719b2ce5c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "gix-commitgraph", "gix-date", "gix-hash", @@ -4305,7 +4368,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest 0.11.2", + "digest 0.11.3", ] [[package]] @@ -4435,9 +4498,9 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "hybrid-array" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" dependencies = [ "typenum", ] @@ -4505,9 +4568,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.8" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2b52f86d1d4bc0d6b4e6826d960b1b333217e07d36b882dca570a5e1c48895b" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http 1.4.0", "hyper 1.9.0", @@ -4699,9 +4762,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -4918,9 +4981,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.23" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" dependencies = [ "jiff-static", "jiff-tzdb-platform", @@ -4933,9 +4996,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.23" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" dependencies = [ "proc-macro2", "quote", @@ -4973,18 +5036,32 @@ dependencies = [ [[package]] name = "jni" -version = "0.21.1" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cesu8", "cfg-if", "combine", - "jni-sys 0.3.1", + "jni-macros", + "jni-sys 0.4.1", "log", - "thiserror 1.0.69", + "simd_cesu8", + "thiserror 2.0.18", "walkdir", - "windows-sys 0.45.0", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", ] [[package]] @@ -5027,9 +5104,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" dependencies = [ "cfg-if", "futures-util", @@ -5119,7 +5196,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "libc", "plain", "redox_syscall 0.7.4", @@ -5554,7 +5631,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "cfg-if", "cfg_aliases", "libc", @@ -5566,7 +5643,7 @@ version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "cfg-if", "cfg_aliases", "libc", @@ -5709,7 +5786,7 @@ dependencies = [ "chrono", "getrandom 0.2.17", "http 1.4.0", - "rand 0.8.5", + "rand 0.8.6", "reqwest 0.12.28", "serde", "serde_json", @@ -5734,7 +5811,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "objc2", "objc2-core-graphics", "objc2-foundation", @@ -5746,7 +5823,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "dispatch2", "objc2", ] @@ -5757,7 +5834,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "dispatch2", "objc2", "objc2-core-foundation", @@ -5776,7 +5853,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "objc2", "objc2-core-foundation", ] @@ -5797,7 +5874,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "objc2", "objc2-core-foundation", ] @@ -5829,11 +5906,11 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "onig" -version = "6.5.1" +version = "6.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "libc", "once_cell", "onig_sys", @@ -5841,9 +5918,9 @@ dependencies = [ [[package]] name = "onig_sys" -version = "69.9.1" +version = "69.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" dependencies = [ "cc", "pkg-config", @@ -5866,7 +5943,7 @@ version = "0.10.78" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "cfg-if", "foreign-types", "libc", @@ -5926,6 +6003,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -5957,9 +6040,9 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pastey" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" +checksum = "c5a797f0e07bdf071d15742978fc3128ec6c22891c31a3a931513263904c982a" [[package]] name = "pathdiff" @@ -6076,7 +6159,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -6134,9 +6217,9 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "plist" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", @@ -6151,7 +6234,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "crc32fast", "fdeflate", "flate2", @@ -6166,9 +6249,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" dependencies = [ "portable-atomic", ] @@ -6370,7 +6453,7 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "memchr", "unicase", ] @@ -6386,9 +6469,9 @@ dependencies = [ [[package]] name = "pxfm" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" [[package]] name = "quick-error" @@ -6398,9 +6481,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" dependencies = [ "memchr", ] @@ -6458,7 +6541,7 @@ dependencies = [ "once_cell", "socket2 0.5.10", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -6505,9 +6588,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -6605,7 +6688,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", ] [[package]] @@ -6614,7 +6697,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", ] [[package]] @@ -6785,7 +6868,7 @@ dependencies = [ "http-body 1.0.1", "http-body-util", "hyper 1.9.0", - "hyper-rustls 0.27.8", + "hyper-rustls 0.27.9", "hyper-util", "js-sys", "log", @@ -6828,7 +6911,7 @@ dependencies = [ "http-body 1.0.1", "http-body-util", "hyper 1.9.0", - "hyper-rustls 0.27.8", + "hyper-rustls 0.27.9", "hyper-util", "js-sys", "log", @@ -6936,7 +7019,7 @@ dependencies = [ "num_cpus", "parking_lot", "pin-project-lite", - "rand 0.8.5", + "rand 0.8.6", "ref-cast", "rocket_codegen", "rocket_http", @@ -7002,7 +7085,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "once_cell", "serde", "serde_derive", @@ -7051,7 +7134,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -7064,7 +7147,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -7094,7 +7177,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.11", + "rustls-webpki 0.103.13", "subtle", "zeroize", ] @@ -7122,9 +7205,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -7132,19 +7215,19 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni 0.21.1", + "jni 0.22.4", "log", "once_cell", "rustls 0.23.40", "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki 0.103.11", + "rustls-webpki 0.103.13", "security-framework", "security-framework-sys", "webpki-root-certs", @@ -7169,9 +7252,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.11" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -7191,7 +7274,7 @@ version = "18.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a990b25f351b25139ddc7f21ee3f6f56f86d6846b74ac8fad3a719a287cd4a0" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "cfg-if", "clipboard-win", "home", @@ -7320,7 +7403,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -7463,9 +7546,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.18.0" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +checksum = "f05839ce67618e14a09b286535c0d9c94e85ef25469b0e13cb4f844e5593eb19" dependencies = [ "base64 0.22.1", "chrono", @@ -7482,9 +7565,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.18.0" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -7584,7 +7667,7 @@ checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "digest 0.11.2", + "digest 0.11.3", ] [[package]] @@ -7664,6 +7747,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "similar" version = "2.7.0" @@ -7681,9 +7780,9 @@ dependencies = [ [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -7725,9 +7824,9 @@ checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" [[package]] name = "sqlite-wasm-rs" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36" dependencies = [ "cc", "js-sys", @@ -7737,9 +7836,9 @@ dependencies = [ [[package]] name = "sse-stream" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c5e6deb40826033bd7b11c7ef25ef71193fabd71f680f40dd16538a2704d2f4" +checksum = "f3962b63f038885f15bce2c6e02c0e7925c072f1ac86bb60fd44c5c6b762fb72" dependencies = [ "bytes", "futures-util", @@ -8384,6 +8483,7 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -8424,7 +8524,7 @@ dependencies = [ "serde_spanned 1.1.1", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.1", + "winnow 1.0.2", ] [[package]] @@ -8480,7 +8580,7 @@ dependencies = [ "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.1", + "winnow 1.0.2", ] [[package]] @@ -8489,7 +8589,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.1", + "winnow 1.0.2", ] [[package]] @@ -8600,7 +8700,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "async-compression", - "bitflags 2.11.0", + "bitflags 2.11.1", "bytes", "futures-core", "futures-util", @@ -8740,9 +8840,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "ubyte" @@ -9024,11 +9124,11 @@ dependencies = [ [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -9037,7 +9137,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] @@ -9057,9 +9157,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" dependencies = [ "cfg-if", "once_cell", @@ -9070,9 +9170,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" dependencies = [ "js-sys", "wasm-bindgen", @@ -9080,9 +9180,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -9090,9 +9190,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" dependencies = [ "bumpalo", "proc-macro2", @@ -9103,9 +9203,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" dependencies = [ "unicode-ident", ] @@ -9151,7 +9251,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -9159,9 +9259,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" dependencies = [ "js-sys", "wasm-bindgen", @@ -9179,18 +9279,18 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] [[package]] name = "webpki-roots" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] @@ -9487,15 +9587,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.48.0" @@ -9525,26 +9616,20 @@ dependencies = [ [[package]] name = "windows-sys" -version = "0.61.2" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-link 0.2.1", + "windows-targets 0.53.5", ] [[package]] -name = "windows-targets" -version = "0.42.2" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", + "windows-link 0.2.1", ] [[package]] @@ -9571,13 +9656,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -9596,12 +9698,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -9615,10 +9711,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" +name = "windows_aarch64_gnullvm" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" @@ -9633,10 +9729,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] -name = "windows_i686_gnu" -version = "0.42.2" +name = "windows_aarch64_msvc" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" @@ -9650,6 +9746,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" @@ -9657,10 +9759,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] -name = "windows_i686_msvc" -version = "0.42.2" +name = "windows_i686_gnullvm" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" @@ -9675,10 +9777,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" +name = "windows_i686_msvc" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" @@ -9693,10 +9795,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" +name = "windows_x86_64_gnu" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" @@ -9711,10 +9813,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" +name = "windows_x86_64_gnullvm" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" @@ -9728,6 +9830,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.7.15" @@ -9739,9 +9847,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" dependencies = [ "memchr", ] @@ -9775,6 +9883,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" @@ -9824,7 +9938,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.0", + "bitflags 2.11.1", "indexmap 2.14.0", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index 208aa18735..a54df57137 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ opt-level = 3 strip = true [workspace.dependencies] +agent-client-protocol = { version = "0.9", features = ["unstable_session_model"] } anyhow = "1.0.102" async-recursion = "1.1.1" async-stream = "0.3" @@ -100,6 +101,7 @@ tokio = { version = "1.51.0", features = [ "process", "signal", "io-util", + "io-std", ] } tokio-stream = "0.1.18" tokio-util = "0.7" diff --git a/crates/forge_api/src/api.rs b/crates/forge_api/src/api.rs index aafb112d49..27b2065cda 100644 --- a/crates/forge_api/src/api.rs +++ b/crates/forge_api/src/api.rs @@ -251,6 +251,8 @@ pub trait API: Sync + Send { &self, data_parameters: DataGenerationParameters, ) -> Result>>; + /// Starts the ACP (Agent Communication Protocol) server over stdio. + async fn acp_start_stdio(&self) -> Result<()>; /// Authenticate with an MCP server via OAuth flow async fn mcp_auth(&self, server_url: &str) -> Result<()>; diff --git a/crates/forge_api/src/forge_api.rs b/crates/forge_api/src/forge_api.rs index b56d485bfd..cb84956493 100644 --- a/crates/forge_api/src/forge_api.rs +++ b/crates/forge_api/src/forge_api.rs @@ -404,6 +404,11 @@ impl< app.execute(data_parameters).await } + async fn acp_start_stdio(&self) -> Result<()> { + let acp_app = forge_app::AcpApp::new(self.services.clone()); + acp_app.start_stdio().await + } + async fn get_session_config(&self) -> Option { self.services.get_session_config().await } diff --git a/crates/forge_app/Cargo.toml b/crates/forge_app/Cargo.toml index 6fbd6df6d9..f56ce174e9 100644 --- a/crates/forge_app/Cargo.toml +++ b/crates/forge_app/Cargo.toml @@ -48,6 +48,10 @@ lazy_static.workspace = true forge_json_repair.workspace = true tonic.workspace = true +agent-client-protocol.workspace = true +tokio-util = { workspace = true, features = ["compat"] } +base64.workspace = true +uuid.workspace = true [dev-dependencies] diff --git a/crates/forge_app/src/acp/adapter.rs b/crates/forge_app/src/acp/adapter.rs new file mode 100644 index 0000000000..cd8a6e5582 --- /dev/null +++ b/crates/forge_app/src/acp/adapter.rs @@ -0,0 +1,301 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use agent_client_protocol as acp; +use forge_domain::{AgentId, ConversationId, ModelId}; +use tokio::sync::{Mutex, Notify, mpsc}; + +use super::error::{Error, Result}; + +/// Maximum number of buffered session notifications before backpressure. +const NOTIFICATION_CHANNEL_CAPACITY: usize = 1024; + +#[derive(Clone)] +pub(super) struct SessionState { + pub conversation_id: ConversationId, + pub agent_id: AgentId, + /// Session-scoped model override. When set, prompts use this model + /// instead of the global default. + pub model_id: Option, + pub cancel_notify: Option>, +} + +pub(crate) struct AcpAdapter { + pub(super) services: Arc, + pub(super) session_update_tx: mpsc::Sender, + pub(super) client_conn: Arc>>>, + sessions: Arc>>, +} + +impl AcpAdapter { + fn with_services(services: Arc) -> (Self, mpsc::Receiver) { + let (tx, rx) = mpsc::channel(NOTIFICATION_CHANNEL_CAPACITY); + let adapter = Self { + services, + session_update_tx: tx, + client_conn: Arc::new(Mutex::new(None)), + sessions: Arc::new(Mutex::new(HashMap::new())), + }; + (adapter, rx) + } + + #[cfg(test)] + pub(super) fn new_for_test(services: S) -> Self { + Self::with_services(Arc::new(services)).0 + } + + #[cfg(test)] + pub(super) fn new_for_test_with_receiver( + services: S, + ) -> (Self, mpsc::Receiver) { + Self::with_services(Arc::new(services)) + } +} + +impl AcpAdapter { + pub(crate) async fn set_client_connection(&self, conn: Arc) { + *self.client_conn.lock().await = Some(conn); + } + + pub(super) async fn store_session(&self, session_id: String, state: SessionState) { + self.sessions.lock().await.insert(session_id, state); + } + + /// Removes a session from the adapter. Currently unused but available + /// for future session lifecycle management (TTL, explicit close). + #[allow(dead_code)] + pub(super) async fn remove_session(&self, session_id: &str) { + self.sessions.lock().await.remove(session_id); + } + + pub(super) async fn session_state(&self, session_id: &str) -> Result { + self.sessions + .lock() + .await + .get(session_id) + .cloned() + .ok_or_else(|| Error::Application(anyhow::anyhow!("Session not found"))) + } + + pub(super) async fn update_session_agent( + &self, + session_id: &str, + agent_id: AgentId, + ) -> Result<()> { + let mut sessions = self.sessions.lock().await; + let state = sessions + .get_mut(session_id) + .ok_or_else(|| Error::Application(anyhow::anyhow!("Session not found")))?; + state.agent_id = agent_id; + Ok(()) + } + + pub(super) async fn update_session_model( + &self, + session_id: &str, + model_id: ModelId, + ) -> Result<()> { + let mut sessions = self.sessions.lock().await; + let state = sessions + .get_mut(session_id) + .ok_or_else(|| Error::Application(anyhow::anyhow!("Session not found")))?; + state.model_id = Some(model_id); + Ok(()) + } + + pub(super) async fn set_cancel_notify( + &self, + session_id: &str, + cancel_notify: Option>, + ) -> Result<()> { + let mut sessions = self.sessions.lock().await; + let state = sessions + .get_mut(session_id) + .ok_or_else(|| Error::Application(anyhow::anyhow!("Session not found")))?; + state.cancel_notify = cancel_notify; + Ok(()) + } + + pub(super) async fn cancel_session(&self, session_id: &str) -> bool { + let notify = self + .sessions + .lock() + .await + .get(session_id) + .and_then(|state| state.cancel_notify.clone()); + + if let Some(notify) = notify { + notify.notify_waiters(); + true + } else { + false + } + } + + pub(super) async fn ensure_session( + &self, + session_id: &str, + conversation_id: ConversationId, + agent_id: AgentId, + ) -> SessionState { + let mut sessions = self.sessions.lock().await; + sessions + .entry(session_id.to_string()) + .or_insert_with(|| SessionState { + conversation_id, + agent_id, + model_id: None, + cancel_notify: None, + }) + .clone() + } + + pub(super) fn send_notification(&self, notification: acp::SessionNotification) -> Result<()> { + self.session_update_tx + .try_send(notification) + .map_err(|_| Error::Application(anyhow::anyhow!("Failed to send notification"))) + } +} + +impl AcpAdapter { + /// Creates a new ACP adapter and returns the notification receiver. + pub(crate) fn new( + services: Arc, + ) -> (Self, mpsc::Receiver) { + Self::with_services(services) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::time::Duration; + + use forge_domain::{AgentId, ConversationId, ModelId}; + use tokio::sync::Notify; + + use super::{AcpAdapter, SessionState}; + + #[tokio::test] + async fn ensure_session_keeps_existing_state() { + let adapter = AcpAdapter::new_for_test(()); + let conversation_id = ConversationId::generate(); + let notify = Arc::new(Notify::new()); + + adapter + .store_session( + "session-1".to_string(), + SessionState { + conversation_id: conversation_id.clone(), + agent_id: AgentId::new("original-agent"), + model_id: Some(ModelId::new("model-a")), + cancel_notify: Some(notify.clone()), + }, + ) + .await; + + let actual = adapter + .ensure_session( + "session-1", + ConversationId::generate(), + AgentId::new("replacement-agent"), + ) + .await; + + assert_eq!(actual.conversation_id, conversation_id); + assert_eq!(actual.agent_id, AgentId::new("original-agent")); + assert_eq!(actual.model_id, Some(ModelId::new("model-a"))); + assert!(actual.cancel_notify.is_some()); + } + + #[tokio::test] + async fn ensure_session_creates_new_state_when_missing() { + let adapter = AcpAdapter::new_for_test(()); + let conversation_id = ConversationId::generate(); + + let actual = adapter + .ensure_session( + "new-session", + conversation_id.clone(), + AgentId::new("fresh-agent"), + ) + .await; + + assert_eq!(actual.conversation_id, conversation_id); + assert_eq!(actual.agent_id, AgentId::new("fresh-agent")); + assert_eq!(actual.model_id, None); + assert!(actual.cancel_notify.is_none()); + } + + #[tokio::test] + async fn cancel_session_notifies_waiters() { + let adapter = AcpAdapter::new_for_test(()); + let notify = Arc::new(Notify::new()); + let wait_for_cancel_handle = notify.clone(); + let wait_for_cancel = wait_for_cancel_handle.notified(); + + adapter + .store_session( + "session-2".to_string(), + SessionState { + conversation_id: ConversationId::generate(), + agent_id: AgentId::new("agent"), + model_id: None, + cancel_notify: Some(notify), + }, + ) + .await; + + let cancelled = adapter.cancel_session("session-2").await; + + assert!(cancelled); + let result = tokio::time::timeout(Duration::from_millis(100), wait_for_cancel).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn cancel_session_returns_false_when_session_has_no_waiter() { + let adapter = AcpAdapter::new_for_test(()); + + let cancelled = adapter.cancel_session("missing-session").await; + + assert!(!cancelled); + } + + #[tokio::test] + async fn update_methods_change_existing_session() { + let adapter = AcpAdapter::new_for_test(()); + let notify = Arc::new(Notify::new()); + + adapter + .store_session( + "session-3".to_string(), + SessionState { + conversation_id: ConversationId::generate(), + agent_id: AgentId::new("old-agent"), + model_id: None, + cancel_notify: None, + }, + ) + .await; + + adapter + .update_session_agent("session-3", AgentId::new("new-agent")) + .await + .unwrap(); + adapter + .update_session_model("session-3", ModelId::new("new-model")) + .await + .unwrap(); + adapter + .set_cancel_notify("session-3", Some(notify.clone())) + .await + .unwrap(); + + let actual = adapter.session_state("session-3").await.unwrap(); + + assert_eq!(actual.agent_id, AgentId::new("new-agent")); + assert_eq!(actual.model_id, Some(ModelId::new("new-model"))); + assert!(actual.cancel_notify.is_some()); + } +} diff --git a/crates/forge_app/src/acp/conversion.rs b/crates/forge_app/src/acp/conversion.rs new file mode 100644 index 0000000000..640fe42868 --- /dev/null +++ b/crates/forge_app/src/acp/conversion.rs @@ -0,0 +1,352 @@ +use std::path::PathBuf; + +use agent_client_protocol as acp; +use forge_domain::{ + Agent, AgentId, Attachment, AttachmentContent, FileInfo, ToolCallFull, ToolName, ToolOutput, + ToolValue, +}; + +use super::error::{Error, Result}; + +/// Maximum size in bytes for base64-encoded blob resources. +/// Protects against OOM from oversized client payloads. +const MAX_BLOB_SIZE: usize = 50 * 1024 * 1024; // 50 MB + +/// Maps a Forge tool name to an ACP ToolKind. +/// +/// Native Forge tools are classified by exact match. MCP tools (prefixed +/// with `mcp_`) use best-effort keyword heuristics and default to `Other` +/// when the name is ambiguous. The heuristic is order-dependent: the first +/// matching keyword category wins. +pub(crate) fn map_tool_kind(tool_name: &ToolName) -> acp::ToolKind { + match tool_name.as_str() { + "read" => acp::ToolKind::Read, + "write" | "patch" => acp::ToolKind::Edit, + "remove" | "undo" => acp::ToolKind::Delete, + "fs_search" | "sem_search" => acp::ToolKind::Search, + "shell" => acp::ToolKind::Execute, + "fetch" => acp::ToolKind::Fetch, + "sage" => acp::ToolKind::Think, + _ => classify_mcp_tool(tool_name.as_str()), + } +} + +/// Best-effort classification for MCP tools by keyword heuristic. +/// +/// Falls back to `Other` for non-MCP tools or when no keyword matches. +/// The match order matters: a tool named `mcp_get_search_results` would +/// classify as `Read` (matches "get" before "search"). +fn classify_mcp_tool(name: &str) -> acp::ToolKind { + if !name.starts_with("mcp_") { + return acp::ToolKind::Other; + } + + // Strip the "mcp__" prefix to get the action portion. + // E.g. "mcp_github_list_issues" → check against "list_issues". + let action = name + .strip_prefix("mcp_") + .and_then(|rest| rest.split_once('_').map(|(_, action)| action)) + .unwrap_or(name); + + const READ_KEYWORDS: &[&str] = &["read", "get", "fetch", "list", "show", "view", "load"]; + const SEARCH_KEYWORDS: &[&str] = &["search", "query", "find", "filter", "lookup"]; + const EDIT_KEYWORDS: &[&str] = &[ + "write", "update", "create", "set", "add", "insert", "push", "merge", + "fork", "comment", "assign", "request", + ]; + const DELETE_KEYWORDS: &[&str] = &["delete", "remove", "drop", "clear", "close", "cancel"]; + const EXECUTE_KEYWORDS: &[&str] = &["execute", "run", "start", "invoke", "call"]; + + let checks: &[(&[&str], acp::ToolKind)] = &[ + (READ_KEYWORDS, acp::ToolKind::Read), + (SEARCH_KEYWORDS, acp::ToolKind::Search), + (EDIT_KEYWORDS, acp::ToolKind::Edit), + (DELETE_KEYWORDS, acp::ToolKind::Delete), + (EXECUTE_KEYWORDS, acp::ToolKind::Execute), + ]; + + for (keywords, kind) in checks { + if keywords.iter().any(|kw| action.contains(kw)) { + return kind.clone(); + } + } + + acp::ToolKind::Other +} + +pub(crate) fn extract_file_locations( + tool_name: &ToolName, + arguments: &serde_json::Value, +) -> Vec { + match tool_name.as_str() { + "read" | "write" | "patch" | "remove" | "undo" => arguments + .get("file_path") + .and_then(|value| value.as_str()) + .map(|file_path| vec![acp::ToolCallLocation::new(PathBuf::from(file_path))]) + .unwrap_or_default(), + _ => vec![], + } +} + +pub(crate) fn map_tool_call_to_acp(tool_call: &ToolCallFull) -> acp::ToolCall { + let tool_call_id = tool_call + .call_id + .as_ref() + .map(|id| id.as_str().to_string()) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + + let locations = extract_file_locations( + &tool_call.name, + &serde_json::to_value(&tool_call.arguments).unwrap_or(serde_json::json!({})), + ); + + acp::ToolCall::new(tool_call_id, tool_call.name.as_str().to_string()) + .kind(map_tool_kind(&tool_call.name)) + .status(acp::ToolCallStatus::Pending) + .locations(locations) + .raw_input( + serde_json::to_value(&tool_call.arguments) + .ok() + .filter(|value| !value.is_null()), + ) +} + +/// Converts a ToolOutput into ACP content blocks. +pub(crate) fn convert_tool_output(output: &ToolOutput) -> Vec { + output + .values + .iter() + .filter_map(convert_tool_value) + .collect() +} + +fn convert_tool_value(value: &ToolValue) -> Option { + match value { + ToolValue::Text(text) => convert_text(text), + ToolValue::AI { value, .. } => convert_text(value), + ToolValue::Image(image) => Some(acp::ToolCallContent::Content(acp::Content::new( + acp::ContentBlock::Image(acp::ImageContent::new(image.data(), image.mime_type())), + ))), + ToolValue::Empty => None, + } +} + +fn convert_text(text: &str) -> Option { + if text.is_empty() { + None + } else { + Some(acp::ToolCallContent::Content(acp::Content::new( + acp::ContentBlock::Text(acp::TextContent::new(text.to_string())), + ))) + } +} + +pub(crate) fn acp_resource_to_attachment(resource: &acp::EmbeddedResource) -> Result { + let (content_text, uri) = match &resource.resource { + acp::EmbeddedResourceResource::TextResourceContents(text_resource) => { + (text_resource.text.clone(), text_resource.uri.clone()) + } + acp::EmbeddedResourceResource::BlobResourceContents(blob_resource) => { + if blob_resource.blob.len() > MAX_BLOB_SIZE { + return Err(Error::Application(anyhow::anyhow!( + "Blob resource exceeds maximum size of {} bytes", + MAX_BLOB_SIZE + ))); + } + let decoded = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + &blob_resource.blob, + ) + .map_err(|error| { + Error::Application(anyhow::anyhow!("Failed to decode base64 blob: {}", error)) + })?; + let text = String::from_utf8(decoded).map_err(|error| { + Error::Application(anyhow::anyhow!("Failed to decode UTF-8: {}", error)) + })?; + (text, blob_resource.uri.clone()) + } + _ => { + return Err(Error::Application(anyhow::anyhow!( + "Unsupported resource type" + ))) + } + }; + + let path = uri_to_path(&uri); + let total_lines = content_text.lines().count() as u64; + let info = FileInfo::new(1, total_lines, total_lines, String::new()); + let content = AttachmentContent::FileContent { + content: content_text, + info, + }; + + Ok(Attachment { path, content }) +} + +pub(crate) fn uri_to_path(uri: &str) -> String { + if let Some(path) = uri.strip_prefix("file://") { + if path.len() > 2 && path.chars().nth(2) == Some(':') { + path.trim_start_matches('/').to_string() + } else { + path.to_string() + } + } else { + uri.to_string() + } +} + +pub(crate) fn build_session_mode_state( + agents: &[Agent], + current_agent_id: &AgentId, +) -> acp::SessionModeState { + let available_modes = agents + .iter() + .map(|agent| { + acp::SessionMode::new( + acp::SessionModeId::new(agent.id.to_string()), + agent.id.to_string(), + ) + .description(agent.description.clone()) + }) + .collect(); + + acp::SessionModeState::new( + acp::SessionModeId::new(current_agent_id.to_string()), + available_modes, + ) +} + +#[cfg(test)] +mod tests { + use forge_domain::{ConversationId, Image}; + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_uri_to_path_preserves_non_file_uri() { + let fixture = "relative/path.txt"; + let actual = uri_to_path(fixture); + let expected = "relative/path.txt".to_string(); + assert_eq!(actual, expected); + } + + #[test] + fn test_uri_to_path_strips_file_prefix() { + let fixture = "file:///home/user/file.txt"; + let actual = uri_to_path(fixture); + let expected = "/home/user/file.txt".to_string(); + assert_eq!(actual, expected); + } + + #[test] + fn test_markdown_sent_to_acp_not_xml() { + let fixture = ToolOutput::text("## File: test.txt\n\nContent here"); + + let actual = convert_tool_output(&fixture); + + assert_eq!(actual.len(), 1); + if let Some(acp::ToolCallContent::Content(content)) = actual.first() { + if let acp::ContentBlock::Text(text) = &content.content { + assert_eq!(text.text, "## File: test.txt\n\nContent here"); + } else { + panic!("Expected text content block"); + } + } else { + panic!("Expected content"); + } + } + + #[test] + fn test_ai_output_sent_to_acp_as_text() { + let fixture = ToolOutput::ai(ConversationId::generate(), "Agent result"); + + let actual = convert_tool_output(&fixture); + + assert_eq!(actual.len(), 1); + if let Some(acp::ToolCallContent::Content(content)) = actual.first() { + if let acp::ContentBlock::Text(text) = &content.content { + assert_eq!(text.text, "Agent result"); + } else { + panic!("Expected text content block"); + } + } else { + panic!("Expected content"); + } + } + + #[test] + fn test_image_sent_to_acp() { + let image = Image::new_bytes(vec![1, 2, 3, 4], "image/png".to_string()); + let fixture = ToolOutput::image(image); + + let actual = convert_tool_output(&fixture); + + assert_eq!(actual.len(), 1); + if let Some(acp::ToolCallContent::Content(content)) = actual.first() { + assert!(matches!(content.content, acp::ContentBlock::Image(_))); + } else { + panic!("Expected content"); + } + } + + #[test] + fn test_empty_output_produces_no_content() { + let fixture = ToolOutput::text(""); + let actual = convert_tool_output(&fixture); + let expected: Vec = vec![]; + assert_eq!(actual.len(), expected.len()); + } + + #[test] + fn test_map_tool_kind_native_tools() { + let fixture = ToolName::new("read"); + let actual = map_tool_kind(&fixture); + assert!(matches!(actual, acp::ToolKind::Read)); + } + + #[test] + fn test_map_tool_kind_mcp_read() { + let fixture = ToolName::new("mcp_github_list_issues"); + let actual = map_tool_kind(&fixture); + assert!(matches!(actual, acp::ToolKind::Read)); + } + + #[test] + fn test_map_tool_kind_mcp_search() { + let fixture = ToolName::new("mcp_db_search_records"); + let actual = map_tool_kind(&fixture); + assert!(matches!(actual, acp::ToolKind::Search)); + } + + #[test] + fn test_map_tool_kind_unknown_defaults_to_other() { + let fixture = ToolName::new("mcp_custom_foobar"); + let actual = map_tool_kind(&fixture); + assert!(matches!(actual, acp::ToolKind::Other)); + } + + #[test] + fn test_map_tool_kind_non_mcp_unknown() { + let fixture = ToolName::new("custom_tool"); + let actual = map_tool_kind(&fixture); + assert!(matches!(actual, acp::ToolKind::Other)); + } + + #[test] + fn test_extract_file_locations_read_tool() { + let fixture_name = ToolName::new("read"); + let fixture_args = serde_json::json!({"file_path": "/tmp/test.rs"}); + let actual = extract_file_locations(&fixture_name, &fixture_args); + assert_eq!(actual.len(), 1); + } + + #[test] + fn test_extract_file_locations_unknown_tool() { + let fixture_name = ToolName::new("shell"); + let fixture_args = serde_json::json!({"command": "ls"}); + let actual = extract_file_locations(&fixture_name, &fixture_args); + let expected: Vec = vec![]; + assert_eq!(actual.len(), expected.len()); + } +} diff --git a/crates/forge_app/src/acp/error.rs b/crates/forge_app/src/acp/error.rs new file mode 100644 index 0000000000..1b5aad0d0c --- /dev/null +++ b/crates/forge_app/src/acp/error.rs @@ -0,0 +1,69 @@ +use agent_client_protocol as acp; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("ACP protocol error: {0}")] + Protocol(#[from] acp::Error), + + #[error("Forge application error: {0}")] + Application(#[from] anyhow::Error), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} + +/// Converts a domain Error into an acp::Error. +/// +/// AGENTS.md forbids blanket `From` impls for domain error conversion. +/// Call this explicitly at each `.map_err()` site instead. +pub fn into_acp_error(error: Error) -> acp::Error { + match error { + Error::Protocol(error) => error, + Error::Application(error) => { + acp::Error::into_internal_error(error.as_ref() as &dyn std::error::Error) + } + Error::Io(error) => acp::Error::into_internal_error(&error), + } +} + +#[cfg(test)] +mod tests { + use std::io; + + use agent_client_protocol as acp; + + use super::{Error, into_acp_error}; + + #[test] + fn preserves_protocol_errors() { + let error = acp::Error::invalid_params(); + + let actual = into_acp_error(Error::Protocol(error.clone())); + + assert_eq!(actual.code, error.code); + assert_eq!(actual.message, error.message); + } + + #[test] + fn wraps_application_errors_as_internal_errors() { + let actual = into_acp_error(Error::Application(anyhow::anyhow!("boom"))); + + assert_eq!(actual.code, acp::ErrorCode::InternalError); + assert_eq!(actual.message, "Internal error"); + assert_eq!(actual.data, Some(serde_json::Value::String("boom".to_string()))); + } + + #[test] + fn wraps_io_errors_as_internal_errors() { + let actual = into_acp_error(Error::Io(io::Error::other("disk failure"))); + + assert_eq!(actual.code, acp::ErrorCode::InternalError); + assert_eq!(actual.message, "Internal error"); + assert_eq!( + actual.data, + Some(serde_json::Value::String("disk failure".to_string())) + ); + } +} diff --git a/crates/forge_app/src/acp/mod.rs b/crates/forge_app/src/acp/mod.rs new file mode 100644 index 0000000000..e5b8e1445a --- /dev/null +++ b/crates/forge_app/src/acp/mod.rs @@ -0,0 +1,90 @@ +mod adapter; +mod conversion; +mod error; +mod prompt_handler; +mod session_handlers; +mod state_builders; + +pub(crate) use adapter::AcpAdapter; + +#[async_trait::async_trait(?Send)] +impl> + agent_client_protocol::Agent for AcpAdapter +{ + async fn initialize( + &self, + arguments: agent_client_protocol::InitializeRequest, + ) -> std::result::Result< + agent_client_protocol::InitializeResponse, + agent_client_protocol::Error, + > { + self.handle_initialize(arguments).await + } + + async fn authenticate( + &self, + arguments: agent_client_protocol::AuthenticateRequest, + ) -> std::result::Result< + agent_client_protocol::AuthenticateResponse, + agent_client_protocol::Error, + > { + self.handle_authenticate(arguments).await + } + + async fn new_session( + &self, + arguments: agent_client_protocol::NewSessionRequest, + ) -> std::result::Result< + agent_client_protocol::NewSessionResponse, + agent_client_protocol::Error, + > { + self.handle_new_session(arguments).await + } + + async fn load_session( + &self, + arguments: agent_client_protocol::LoadSessionRequest, + ) -> std::result::Result< + agent_client_protocol::LoadSessionResponse, + agent_client_protocol::Error, + > { + self.handle_load_session(arguments).await + } + + async fn prompt( + &self, + arguments: agent_client_protocol::PromptRequest, + ) -> std::result::Result< + agent_client_protocol::PromptResponse, + agent_client_protocol::Error, + > { + self.handle_prompt(arguments).await + } + + async fn cancel( + &self, + arguments: agent_client_protocol::CancelNotification, + ) -> std::result::Result<(), agent_client_protocol::Error> { + self.handle_cancel(arguments).await + } + + async fn set_session_mode( + &self, + arguments: agent_client_protocol::SetSessionModeRequest, + ) -> std::result::Result< + agent_client_protocol::SetSessionModeResponse, + agent_client_protocol::Error, + > { + self.handle_set_session_mode(arguments).await + } + + async fn set_session_model( + &self, + arguments: agent_client_protocol::SetSessionModelRequest, + ) -> std::result::Result< + agent_client_protocol::SetSessionModelResponse, + agent_client_protocol::Error, + > { + self.handle_set_session_model(arguments).await + } +} diff --git a/crates/forge_app/src/acp/prompt_handler.rs b/crates/forge_app/src/acp/prompt_handler.rs new file mode 100644 index 0000000000..3cfb78fd0a --- /dev/null +++ b/crates/forge_app/src/acp/prompt_handler.rs @@ -0,0 +1,431 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use agent_client_protocol as acp; +use agent_client_protocol::Client; +use forge_config::ForgeConfig; +use forge_domain::{ + ChatRequest, ChatResponse, ChatResponseContent, Event, EventValue, InterruptionReason, +}; +use futures::StreamExt; +use tokio::sync::Notify; + +use crate::{EnvironmentInfra, ForgeApp, Services}; + +use super::adapter::AcpAdapter; +use super::conversion; +use super::error::{self, Error, Result}; + +impl> AcpAdapter { + pub(super) async fn handle_prompt( + &self, + arguments: acp::PromptRequest, + ) -> std::result::Result { + let session_key = arguments.session_id.0.as_ref().to_string(); + let session = self.session_state(&session_key).await.map_err(error::into_acp_error)?; + + let mut prompt_text_parts = Vec::new(); + let mut attachments = Vec::new(); + + for content_block in &arguments.prompt { + match content_block { + acp::ContentBlock::Text(text_content) => { + prompt_text_parts.push(text_content.text.clone()); + } + acp::ContentBlock::ResourceLink(resource_link) => { + let path = conversion::uri_to_path(&resource_link.uri); + prompt_text_parts.push(format!("@[{}]", path)); + } + acp::ContentBlock::Resource(embedded_resource) => { + match conversion::acp_resource_to_attachment(embedded_resource) { + Ok(attachment) => attachments.push(attachment), + Err(error) => { + tracing::warn!("Failed to convert embedded resource: {}", error); + } + } + } + _ => {} + } + } + + let prompt_text = prompt_text_parts.join("\n"); + let cancel_notify = Arc::new(Notify::new()); + let cancelled = Arc::new(AtomicBool::new(false)); + self.set_cancel_notify(&session_key, Some(cancel_notify.clone())) + .await + .map_err(error::into_acp_error)?; + + let response = self + .run_prompt_loop( + &arguments.session_id, + &session_key, + session, + prompt_text, + attachments, + cancel_notify, + cancelled, + ) + .await; + + let _ = self.set_cancel_notify(&session_key, None).await; + response + } + + async fn run_prompt_loop( + &self, + session_id: &acp::SessionId, + session_key: &str, + session: super::adapter::SessionState, + prompt_text: String, + attachments: Vec, + cancel_notify: Arc, + cancelled: Arc, + ) -> std::result::Result { + let mut event = Event::new(EventValue::text(prompt_text)); + event.attachments = attachments; + + let mut chat_request = ChatRequest::new(event, session.conversation_id); + loop { + // Check if cancellation was requested before starting a new + // chat round (handles the case where cancel arrives between + // loop iterations). + if cancelled.load(Ordering::SeqCst) { + tracing::info!("ACP prompt cancelled for session {}", session_key); + return Ok(acp::PromptResponse::new(acp::StopReason::Cancelled)); + } + + let app = ForgeApp::new(self.services.clone()); + let mut stream = app + .chat(session.agent_id.clone(), chat_request) + .await + .map_err(|error| acp::Error::into_internal_error(error.as_ref() as &dyn std::error::Error))?; + + let mut continue_after_interrupt = false; + + loop { + tokio::select! { + _ = cancel_notify.notified() => { + cancelled.store(true, Ordering::SeqCst); + tracing::info!("ACP prompt cancelled for session {}", session_key); + return Ok(acp::PromptResponse::new(acp::StopReason::Cancelled)); + } + response_result = stream.next() => { + match response_result { + Some(Ok(response)) => { + self.handle_chat_response(session_id, response, &mut continue_after_interrupt).await?; + } + Some(Err(error)) => { + tracing::error!("Error in chat stream: {}", error); + return Err(acp::Error::into_internal_error( + error.as_ref() as &dyn std::error::Error, + )); + } + None => { + break; + } + } + } + } + } + + if continue_after_interrupt { + chat_request = ChatRequest::new(Event::new(EventValue::text("")), session.conversation_id); + continue; + } + + return Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)); + } + } + +} + +impl AcpAdapter { + async fn handle_chat_response( + &self, + session_id: &acp::SessionId, + response: ChatResponse, + continue_after_interrupt: &mut bool, + ) -> std::result::Result<(), acp::Error> { + match response { + ChatResponse::TaskMessage { content } => { + self.handle_task_message(session_id, content).await?; + } + ChatResponse::TaskReasoning { content } => { + if !content.is_empty() { + let notification = acp::SessionNotification::new( + session_id.clone(), + acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new( + acp::ContentBlock::Text(acp::TextContent::new(content)), + )), + ); + self.send_notification(notification) + .map_err(error::into_acp_error)?; + } + } + ChatResponse::ToolCallStart { tool_call, .. } => { + let notification = acp::SessionNotification::new( + session_id.clone(), + acp::SessionUpdate::ToolCallUpdate( + conversion::map_tool_call_to_acp(&tool_call).into(), + ), + ); + self.send_notification(notification) + .map_err(error::into_acp_error)?; + } + ChatResponse::ToolCallEnd(tool_result) => { + let content = conversion::convert_tool_output(&tool_result.output); + let status = if tool_result.output.is_error { + acp::ToolCallStatus::Failed + } else { + acp::ToolCallStatus::Completed + }; + let tool_call_id = tool_result + .call_id + .as_ref() + .map(|id| id.as_str().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let update = acp::ToolCallUpdate::new( + tool_call_id, + acp::ToolCallUpdateFields::new().status(status).content(content), + ); + let notification = acp::SessionNotification::new( + session_id.clone(), + acp::SessionUpdate::ToolCallUpdate(update), + ); + self.send_notification(notification) + .map_err(error::into_acp_error)?; + } + ChatResponse::TaskComplete => {} + ChatResponse::RetryAttempt { .. } => {} + ChatResponse::Interrupt { reason } => { + let should_continue = self + .request_continue_permission(session_id, &reason) + .await + .map_err(error::into_acp_error)?; + if should_continue { + *continue_after_interrupt = true; + } + } + } + + Ok(()) + } + + async fn handle_task_message( + &self, + session_id: &acp::SessionId, + content: ChatResponseContent, + ) -> std::result::Result<(), acp::Error> { + match content { + ChatResponseContent::ToolOutput(_) => {} + ChatResponseContent::Markdown { text, .. } => { + if !text.is_empty() { + let notification = acp::SessionNotification::new( + session_id.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + acp::ContentBlock::Text(acp::TextContent::new(text)), + )), + ); + self.send_notification(notification) + .map_err(error::into_acp_error)?; + } + } + ChatResponseContent::ToolInput(_) => {} + } + + Ok(()) + } + + async fn request_continue_permission( + &self, + session_id: &acp::SessionId, + reason: &InterruptionReason, + ) -> Result { + let client_conn = self.client_conn.lock().await; + let Some(conn) = client_conn.as_ref() else { + return Ok(false); + }; + + let (title, description) = format_interruption(reason); + let options = vec![ + acp::PermissionOption::new( + "continue", + "Continue Anyway", + acp::PermissionOptionKind::AllowOnce, + ), + acp::PermissionOption::new("stop", "Stop", acp::PermissionOptionKind::RejectOnce), + ]; + let tool_call_update = acp::ToolCallUpdate::new( + "interrupt-continue", + acp::ToolCallUpdateFields::new() + .status(acp::ToolCallStatus::Pending) + .title(title.clone()), + ); + + let mut request = acp::RequestPermissionRequest::new( + session_id.clone(), + tool_call_update, + options, + ); + let mut meta = serde_json::Map::new(); + meta.insert("title".to_string(), serde_json::json!(title)); + meta.insert("description".to_string(), serde_json::json!(description)); + request = request.meta(meta); + + let response = conn.request_permission(request).await.map_err(|error| { + Error::Application(anyhow::anyhow!("Permission request failed: {}", error)) + })?; + + match response.outcome { + acp::RequestPermissionOutcome::Selected(selection) => { + Ok(selection.option_id.0.as_ref() == "continue") + } + acp::RequestPermissionOutcome::Cancelled => Ok(false), + _ => Ok(false), + } + } +} + +fn format_interruption(reason: &InterruptionReason) -> (String, String) { + match reason { + InterruptionReason::MaxToolFailurePerTurnLimitReached { limit, errors } => { + let error_summary = errors + .iter() + .map(|(tool_name, count)| format!("{} ({})", tool_name, count)) + .collect::>() + .join(", "); + ( + format!("Tool failure limit reached ({})", limit), + format!("Forge stopped after repeated tool failures: {}", error_summary), + ) + } + InterruptionReason::MaxRequestPerTurnLimitReached { limit } => ( + format!("Request limit reached ({})", limit), + "Forge reached the maximum number of requests for this turn.".to_string(), + ), + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use agent_client_protocol as acp; + use forge_domain::{ChatResponse, ChatResponseContent, InterruptionReason, ToolName}; + use tokio::sync::mpsc::error::TryRecvError; + + use super::{AcpAdapter, format_interruption}; + + #[test] + fn formats_tool_failure_interruptions() { + let mut errors = HashMap::new(); + errors.insert(ToolName::new("read"), 2); + errors.insert(ToolName::new("write"), 1); + + let actual = format_interruption( + &InterruptionReason::MaxToolFailurePerTurnLimitReached { limit: 3, errors }, + ); + + assert_eq!(actual.0, "Tool failure limit reached (3)"); + assert!(actual.1.contains("read (2)")); + assert!(actual.1.contains("write (1)")); + } + + #[test] + fn formats_request_limit_interruptions() { + let actual = format_interruption( + &InterruptionReason::MaxRequestPerTurnLimitReached { limit: 5 }, + ); + + assert_eq!(actual.0, "Request limit reached (5)"); + assert_eq!( + actual.1, + "Forge reached the maximum number of requests for this turn." + ); + } + + #[tokio::test] + async fn task_message_sends_agent_message_notification() { + let (adapter, mut rx) = AcpAdapter::new_for_test_with_receiver(()); + let session_id = acp::SessionId::new("session-1"); + + adapter + .handle_task_message( + &session_id, + ChatResponseContent::Markdown { + text: "Hello from Forge".to_string(), + partial: false, + }, + ) + .await + .unwrap(); + + let actual = rx.try_recv().unwrap(); + assert_eq!(actual.session_id, session_id); + assert!(matches!( + actual.update, + acp::SessionUpdate::AgentMessageChunk(_) + )); + } + + #[tokio::test] + async fn empty_task_message_is_ignored() { + let (adapter, mut rx) = AcpAdapter::new_for_test_with_receiver(()); + + adapter + .handle_task_message( + &acp::SessionId::new("session-2"), + ChatResponseContent::Markdown { + text: String::new(), + partial: false, + }, + ) + .await + .unwrap(); + + assert_eq!(rx.try_recv(), Err(TryRecvError::Empty)); + } + + #[tokio::test] + async fn task_reasoning_sends_thought_notification() { + let (adapter, mut rx) = AcpAdapter::new_for_test_with_receiver(()); + let session_id = acp::SessionId::new("session-3"); + + adapter + .handle_chat_response( + &session_id, + ChatResponse::TaskReasoning { + content: "Thinking".to_string(), + }, + &mut false, + ) + .await + .unwrap(); + + let actual = rx.try_recv().unwrap(); + assert_eq!(actual.session_id, session_id); + assert!(matches!( + actual.update, + acp::SessionUpdate::AgentThoughtChunk(_) + )); + } + + #[tokio::test] + async fn interrupt_without_client_does_not_enable_continue() { + let adapter = AcpAdapter::new_for_test(()); + let mut continue_after_interrupt = false; + + adapter + .handle_chat_response( + &acp::SessionId::new("session-4"), + ChatResponse::Interrupt { + reason: InterruptionReason::MaxRequestPerTurnLimitReached { limit: 2 }, + }, + &mut continue_after_interrupt, + ) + .await + .unwrap(); + + assert!(!continue_after_interrupt); + } +} diff --git a/crates/forge_app/src/acp/session_handlers.rs b/crates/forge_app/src/acp/session_handlers.rs new file mode 100644 index 0000000000..6fc2e0bfaa --- /dev/null +++ b/crates/forge_app/src/acp/session_handlers.rs @@ -0,0 +1,1244 @@ +use agent_client_protocol as acp; +use forge_config::ForgeConfig; +use forge_domain::{AgentId, ConfigOperation, Conversation, ConversationId, ModelConfig, ModelId}; + +use crate::{AgentRegistry, AppConfigService, ConversationService, EnvironmentInfra, Services}; + +use super::adapter::{AcpAdapter, SessionState}; +use super::error; +use super::state_builders::StateBuilders; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +impl AcpAdapter { + pub(super) async fn handle_initialize( + &self, + arguments: acp::InitializeRequest, + ) -> std::result::Result { + tracing::info!("Received initialize request from client: {:?}", arguments.client_info); + + Ok(acp::InitializeResponse::new(acp::ProtocolVersion::V1) + .agent_capabilities( + acp::AgentCapabilities::new().load_session(true).mcp_capabilities( + acp::McpCapabilities::new() + .http(true) + .sse(true), + ), + ) + .agent_info( + acp::Implementation::new("forge".to_string(), VERSION.to_string()) + .title("Forge Code".to_string()), + )) + } + + /// Handles ACP authentication. + /// + /// This is intentionally a no-op. The stdio transport inherits OS-level + /// process isolation: only the parent process (e.g. Acepe) that spawned + /// `forge machine stdio` can read/write the stdin/stdout pipes. No + /// network listener is opened, so no additional authentication is + /// required. See `AcpApp::start_stdio` for the full trust model. + pub(super) async fn handle_authenticate( + &self, + _arguments: acp::AuthenticateRequest, + ) -> std::result::Result { + tracing::debug!("ACP authenticate: no-op (stdio transport uses OS process isolation)"); + Ok(acp::AuthenticateResponse::default()) + } +} + +impl> AcpAdapter { + pub(super) async fn handle_new_session( + &self, + arguments: acp::NewSessionRequest, + ) -> std::result::Result { + if !arguments.mcp_servers.is_empty() { + StateBuilders::load_mcp_servers(self.services.as_ref(), &arguments.mcp_servers) + .await + .map_err(error::into_acp_error)?; + } + + let active_agent_id = self + .services + .agent_registry() + .get_active_agent_id() + .await + .map_err(|error| acp::Error::into_internal_error(&*error))? + .unwrap_or_default(); + + let conversation = Conversation::generate(); + let conversation_id = conversation.id; + self.services + .conversation_service() + .upsert_conversation(conversation) + .await + .map_err(|error| acp::Error::into_internal_error(&*error))?; + + let session_id = acp::SessionId::new(conversation_id.into_string()); + let session_key = session_id.0.as_ref().to_string(); + self.store_session( + session_key, + SessionState { + conversation_id, + agent_id: active_agent_id.clone(), + model_id: None, + cancel_notify: None, + }, + ) + .await; + + let agent = self + .services + .agent_registry() + .get_agent(&active_agent_id) + .await + .map_err(|error| acp::Error::into_internal_error(&*error))? + .ok_or_else(|| { + acp::Error::into_internal_error(&*anyhow::anyhow!( + "Agent '{}' not found", + active_agent_id + )) + })?; + + let mode_state = StateBuilders::build_session_mode_state( + self.services.as_ref(), + &active_agent_id, + ) + .await + .map_err(error::into_acp_error)?; + let model_state = StateBuilders::build_session_model_state(&self.services, &agent) + .await + .map_err(error::into_acp_error)?; + + Ok(acp::NewSessionResponse::new(session_id) + .modes(mode_state) + .models(model_state)) + } + + pub(super) async fn handle_load_session( + &self, + arguments: acp::LoadSessionRequest, + ) -> std::result::Result { + if !arguments.mcp_servers.is_empty() { + StateBuilders::load_mcp_servers(self.services.as_ref(), &arguments.mcp_servers) + .await + .map_err(error::into_acp_error)?; + } + + let session_key = arguments.session_id.0.as_ref().to_string(); + let conversation_id = ConversationId::parse(&session_key) + .map_err(|error| acp::Error::into_internal_error(&error))?; + + let conversation = self + .services + .conversation_service() + .find_conversation(&conversation_id) + .await + .map_err(|error| acp::Error::into_internal_error(&*error))?; + if conversation.is_none() { + return Err(acp::Error::invalid_params()); + } + + let active_agent_id = self + .services + .agent_registry() + .get_active_agent_id() + .await + .map_err(|error| acp::Error::into_internal_error(&*error))? + .unwrap_or_default(); + let state = self + .ensure_session(&session_key, conversation_id, active_agent_id.clone()) + .await; + + let agent = self + .services + .agent_registry() + .get_agent(&state.agent_id) + .await + .map_err(|error| acp::Error::into_internal_error(&*error))? + .ok_or_else(|| acp::Error::invalid_params())?; + + let mode_state = StateBuilders::build_session_mode_state( + self.services.as_ref(), + &state.agent_id, + ) + .await + .map_err(error::into_acp_error)?; + let model_state = StateBuilders::build_session_model_state(&self.services, &agent) + .await + .map_err(error::into_acp_error)?; + + Ok(acp::LoadSessionResponse::new() + .modes(mode_state) + .models(model_state)) + } + + /// Handles session model changes. + /// + /// The model preference is stored per-session so that concurrent ACP + /// clients do not interfere with each other. The global default model + /// is also updated for backward compatibility with non-ACP code paths. + pub(super) async fn handle_set_session_model( + &self, + arguments: acp::SetSessionModelRequest, + ) -> std::result::Result { + let session_key = arguments.session_id.0.as_ref().to_string(); + let model_id = ModelId::new(arguments.model_id.0.to_string()); + + // Store per-session model preference. + self.update_session_model(&session_key, model_id.clone()) + .await + .map_err(error::into_acp_error)?; + + // Also update the global default for backward compatibility. + let provider_id = match self.services.get_session_config().await { + Some(config) => config.provider, + None => { + let state = self + .session_state(&session_key) + .await + .map_err(error::into_acp_error)?; + self.services + .agent_registry() + .get_agent(&state.agent_id) + .await + .map_err(|error| acp::Error::into_internal_error(&*error))? + .map(|agent| agent.provider) + .ok_or_else(acp::Error::invalid_params)? + } + }; + self.services + .update_config(vec![ConfigOperation::SetSessionConfig(ModelConfig::new( + provider_id, + model_id.clone(), + ))]) + .await + .map_err(|error| acp::Error::into_internal_error(&*error))?; + if let Err(error) = self.services.reload_agents().await { + tracing::warn!("Failed to reload agents after model change: {}", error); + } + + let notification = acp::SessionNotification::new( + arguments.session_id, + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + acp::ContentBlock::Text(acp::TextContent::new(format!( + "Model changed to: {}\n\n", + model_id + ))), + )), + ); + if let Err(error) = self.send_notification(notification) { + tracing::warn!("Failed to send model change notification: {}", error); + } + + Ok(acp::SetSessionModelResponse::default()) + } +} + +impl AcpAdapter { + pub(super) async fn handle_cancel( + &self, + arguments: acp::CancelNotification, + ) -> std::result::Result<(), acp::Error> { + let session_key = arguments.session_id.0.as_ref().to_string(); + let cancelled = self.cancel_session(&session_key).await; + if !cancelled { + tracing::warn!("No active ACP prompt to cancel for session {}", session_key); + } + Ok(()) + } + + pub(super) async fn handle_set_session_mode( + &self, + arguments: acp::SetSessionModeRequest, + ) -> std::result::Result { + let session_key = arguments.session_id.0.as_ref().to_string(); + let mode_id = arguments.mode_id.0.as_ref(); + let agent_id = AgentId::new(mode_id); + + self.update_session_agent(&session_key, agent_id.clone()) + .await + .map_err(error::into_acp_error)?; + + let notification = acp::SessionNotification::new( + arguments.session_id, + acp::SessionUpdate::CurrentModeUpdate(acp::CurrentModeUpdate::new( + acp::SessionModeId::new(mode_id.to_string()), + )), + ); + self.send_notification(notification) + .map_err(error::into_acp_error)?; + + Ok(acp::SetSessionModeResponse::new()) + } +} + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, HashMap}; + use std::path::{Path, PathBuf}; + use std::sync::{Arc, Mutex}; + + use agent_client_protocol as acp; + use forge_config::ForgeConfig; + use forge_domain::{ + Agent, AgentId, AnyProvider, Attachment, AuthContextRequest, AuthContextResponse, + AuthMethod, ChatCompletionMessage, CommandOutput, ConfigOperation, Context, Conversation, + ConversationId, File, FileStatus, Image, McpConfig, McpServers, Model, ModelConfig, + ModelId, Node, Provider, ProviderId, ProviderResponse, ProviderType, Scope, SearchParams, + Skill, SyncProgress, Template, ToolCallFull, ToolOutput, URLParamSpec, WorkspaceAuth, + WorkspaceId, WorkspaceInfo, + }; + use reqwest::Url; + + use super::{AcpAdapter, SessionState}; + use crate::infra::EnvironmentInfra; + use crate::services::{ + AgentRegistry, AppConfigService, AttachmentService, AuthService, CommandLoaderService, + ConversationService, CustomInstructionsService, FileDiscoveryService, FollowUpService, + FsPatchService, FsReadService, FsRemoveService, FsSearchService, FsUndoService, + FsWriteService, HttpResponse, ImageReadService, McpConfigManager, McpService, + NetFetchService, PatchOutput, PlanCreateOutput, PlanCreateService, PolicyDecision, + PolicyService, ProviderAuthService, ProviderService, ReadOutput, ResponseContext, + SearchResult, Services, ShellOutput, ShellService, SkillFetchService, TemplateService, + WorkspaceService, + }; + use crate::user::{AuthProviderId, Plan, UsageInfo, User, UserUsage}; + use crate::Walker; + + #[derive(Clone)] + struct SharedState(Arc>); + + #[derive(Clone)] + struct MockServices { + provider_service: MockProviderService, + config_service: MockConfigService, + conversation_service: MockConversationService, + mcp_config_manager: MockMcpConfigService, + agent_registry: MockAgentRegistryService, + noop_service: NoopService, + environment: forge_domain::Environment, + config: ForgeConfig, + } + + struct MockState { + active_agent_id: Option, + agents: Vec, + conversations: HashMap, + provider: Provider, + models: Vec, + mcp_config: McpConfig, + config_updates: Vec>, + } + + #[derive(Clone)] + struct MockProviderService { + state: SharedState, + } + + #[derive(Clone)] + struct MockConfigService { + state: SharedState, + } + + #[derive(Clone)] + struct MockConversationService { + state: SharedState, + } + + #[derive(Clone)] + struct MockMcpConfigService { + state: SharedState, + } + + #[derive(Clone)] + struct MockAgentRegistryService { + state: SharedState, + } + + #[derive(Clone, Default)] + struct NoopService; + + impl MockServices { + fn new() -> Self { + let agent = Agent::new( + AgentId::new("forge"), + ProviderId::OPENAI, + ModelId::new("test-model"), + ) + .title("Forge") + .description("Test agent"); + let provider = Provider { + id: ProviderId::OPENAI, + provider_type: ProviderType::Llm, + response: Some(ProviderResponse::OpenAI), + url: Url::parse("https://api.example.com/chat").unwrap(), + models: None, + auth_methods: vec![AuthMethod::ApiKey], + url_params: Vec::::new(), + credential: None, + custom_headers: None, + }; + let model = Model { + id: ModelId::new("test-model"), + name: Some("Test Model".to_string()), + description: Some("Model used by ACP tests".to_string()), + context_length: Some(8192), + tools_supported: Some(true), + supports_parallel_tool_calls: Some(true), + supports_reasoning: Some(false), + input_modalities: vec![forge_domain::InputModality::Text], + }; + let state = SharedState(Arc::new(Mutex::new(MockState { + active_agent_id: Some(agent.id.clone()), + agents: vec![agent], + conversations: HashMap::new(), + provider: provider.clone(), + models: vec![model], + mcp_config: McpConfig::default(), + config_updates: Vec::new(), + }))); + + Self { + provider_service: MockProviderService { + state: state.clone(), + }, + config_service: MockConfigService { + state: state.clone(), + }, + conversation_service: MockConversationService { + state: state.clone(), + }, + mcp_config_manager: MockMcpConfigService { + state: state.clone(), + }, + agent_registry: MockAgentRegistryService { state }, + noop_service: NoopService, + environment: forge_domain::Environment { + os: "macos".to_string(), + cwd: PathBuf::from("/tmp/project"), + home: Some(PathBuf::from("/tmp/home")), + shell: "/bin/zsh".to_string(), + base_path: PathBuf::from("/tmp/forge"), + }, + config: ForgeConfig::default(), + } + } + + fn insert_conversation(&self, conversation: Conversation) { + self.conversation_service + .state + .0 + .lock() + .unwrap() + .conversations + .insert(conversation.id, conversation); + } + + fn config_updates(&self) -> Vec> { + self.config_service + .state + .0 + .lock() + .unwrap() + .config_updates + .clone() + } + } + + impl EnvironmentInfra for MockServices { + type Config = ForgeConfig; + + fn get_env_var(&self, _key: &str) -> Option { + None + } + + fn get_env_vars(&self) -> BTreeMap { + BTreeMap::new() + } + + fn get_environment(&self) -> forge_domain::Environment { + self.environment.clone() + } + + fn get_config(&self) -> anyhow::Result { + Ok(self.config.clone()) + } + + fn update_environment( + &self, + _ops: Vec, + ) -> impl std::future::Future> + Send { + async { Ok(()) } + } + } + + #[async_trait::async_trait] + impl ProviderService for MockProviderService { + async fn chat( + &self, + _model_id: &ModelId, + _context: Context, + _provider: Provider, + ) -> forge_domain::ResultStream { + todo!("unused in session handler tests") + } + + async fn models(&self, _provider: Provider) -> anyhow::Result> { + Ok(self.state.0.lock().unwrap().models.clone()) + } + + async fn get_provider(&self, _id: ProviderId) -> anyhow::Result> { + Ok(self.state.0.lock().unwrap().provider.clone()) + } + + async fn get_all_providers(&self) -> anyhow::Result> { + Ok(vec![AnyProvider::Url(self.state.0.lock().unwrap().provider.clone())]) + } + + async fn upsert_credential( + &self, + _credential: forge_domain::AuthCredential, + ) -> anyhow::Result<()> { + todo!("unused in session handler tests") + } + + async fn remove_credential(&self, _id: &ProviderId) -> anyhow::Result<()> { + todo!("unused in session handler tests") + } + + async fn migrate_env_credentials( + &self, + ) -> anyhow::Result> { + Ok(None) + } + } + + #[async_trait::async_trait] + impl AppConfigService for MockConfigService { + async fn get_session_config(&self) -> Option { + Some(ModelConfig::new(ProviderId::OPENAI, ModelId::new("test-model"))) + } + + async fn get_commit_config(&self) -> anyhow::Result> { + Ok(None) + } + + async fn get_suggest_config(&self) -> anyhow::Result> { + Ok(None) + } + + async fn get_reasoning_effort(&self) -> anyhow::Result> { + Ok(None) + } + + async fn update_config(&self, ops: Vec) -> anyhow::Result<()> { + self.state.0.lock().unwrap().config_updates.push(ops); + Ok(()) + } + } + + #[async_trait::async_trait] + impl ConversationService for MockConversationService { + async fn find_conversation(&self, id: &ConversationId) -> anyhow::Result> { + Ok(self.state.0.lock().unwrap().conversations.get(id).cloned()) + } + + async fn upsert_conversation(&self, conversation: Conversation) -> anyhow::Result<()> { + self.state + .0 + .lock() + .unwrap() + .conversations + .insert(conversation.id, conversation); + Ok(()) + } + + async fn modify_conversation(&self, id: &ConversationId, f: F) -> anyhow::Result + where + F: FnOnce(&mut Conversation) -> T + Send, + T: Send, + { + let mut guard = self.state.0.lock().unwrap(); + let conversation = guard.conversations.get_mut(id).expect("conversation must exist"); + Ok(f(conversation)) + } + + async fn get_conversations( + &self, + _limit: Option, + ) -> anyhow::Result>> { + Ok(Some( + self.state + .0 + .lock() + .unwrap() + .conversations + .values() + .cloned() + .collect(), + )) + } + + async fn last_conversation(&self) -> anyhow::Result> { + Ok(self + .state + .0 + .lock() + .unwrap() + .conversations + .values() + .last() + .cloned()) + } + + async fn delete_conversation(&self, conversation_id: &ConversationId) -> anyhow::Result<()> { + self.state.0.lock().unwrap().conversations.remove(conversation_id); + Ok(()) + } + } + + #[async_trait::async_trait] + impl AgentRegistry for MockAgentRegistryService { + async fn get_active_agent_id(&self) -> anyhow::Result> { + Ok(self.state.0.lock().unwrap().active_agent_id.clone()) + } + + async fn set_active_agent_id(&self, agent_id: AgentId) -> anyhow::Result<()> { + self.state.0.lock().unwrap().active_agent_id = Some(agent_id); + Ok(()) + } + + async fn get_agents(&self) -> anyhow::Result> { + Ok(self.state.0.lock().unwrap().agents.clone()) + } + + async fn get_agent_infos(&self) -> anyhow::Result> { + Ok(self + .state + .0 + .lock() + .unwrap() + .agents + .iter() + .map(|agent| { + let mut info = forge_domain::AgentInfo::default().id(agent.id.clone()); + if let Some(title) = agent.title.clone() { + info = info.title(title); + } + if let Some(description) = agent.description.clone() { + info = info.description(description); + } + info + }) + .collect()) + } + + async fn get_agent(&self, agent_id: &AgentId) -> anyhow::Result> { + Ok(self + .state + .0 + .lock() + .unwrap() + .agents + .iter() + .find(|agent| &agent.id == agent_id) + .cloned()) + } + + async fn reload_agents(&self) -> anyhow::Result<()> { + Ok(()) + } + } + + #[async_trait::async_trait] + impl McpConfigManager for MockMcpConfigService { + async fn read_mcp_config(&self, _scope: Option<&Scope>) -> anyhow::Result { + Ok(self.state.0.lock().unwrap().mcp_config.clone()) + } + + async fn write_mcp_config(&self, config: &McpConfig, _scope: &Scope) -> anyhow::Result<()> { + self.state.0.lock().unwrap().mcp_config = config.clone(); + Ok(()) + } + } + + #[async_trait::async_trait] + impl McpService for NoopService { + async fn get_mcp_servers(&self) -> anyhow::Result { + Ok(McpServers::default()) + } + + async fn execute_mcp(&self, _call: ToolCallFull) -> anyhow::Result { + todo!("unused in session handler tests") + } + + async fn reload_mcp(&self) -> anyhow::Result<()> { + Ok(()) + } + } + + #[async_trait::async_trait] + impl ProviderAuthService for NoopService { + async fn init_provider_auth( + &self, + _provider_id: ProviderId, + _method: AuthMethod, + ) -> anyhow::Result { + todo!("unused in session handler tests") + } + + async fn complete_provider_auth( + &self, + _provider_id: ProviderId, + _context: AuthContextResponse, + _timeout: std::time::Duration, + ) -> anyhow::Result<()> { + todo!("unused in session handler tests") + } + + async fn refresh_provider_credential( + &self, + provider: Provider, + ) -> anyhow::Result> { + Ok(provider) + } + } + + #[async_trait::async_trait] + impl TemplateService for NoopService { + async fn register_template(&self, _path: PathBuf) -> anyhow::Result<()> { + todo!("unused in session handler tests") + } + + async fn render_template( + &self, + _template: Template, + _object: &V, + ) -> anyhow::Result { + todo!("unused in session handler tests") + } + } + + #[async_trait::async_trait] + impl AttachmentService for NoopService { + async fn attachments(&self, _url: &str) -> anyhow::Result> { + Ok(vec![]) + } + } + + #[async_trait::async_trait] + impl CustomInstructionsService for NoopService { + async fn get_custom_instructions(&self) -> Vec { + vec![] + } + } + + #[async_trait::async_trait] + impl FileDiscoveryService for NoopService { + async fn collect_files(&self, _config: Walker) -> anyhow::Result> { + Ok(vec![]) + } + + async fn list_current_directory(&self) -> anyhow::Result> { + Ok(vec![]) + } + } + + #[async_trait::async_trait] + impl FsWriteService for NoopService { + async fn write( + &self, + _path: String, + _content: String, + _overwrite: bool, + ) -> anyhow::Result { + todo!("unused in session handler tests") + } + } + + #[async_trait::async_trait] + impl PlanCreateService for NoopService { + async fn create_plan( + &self, + _plan_name: String, + _version: String, + _content: String, + ) -> anyhow::Result { + todo!("unused in session handler tests") + } + } + + #[async_trait::async_trait] + impl FsPatchService for NoopService { + async fn patch( + &self, + _path: String, + _search: String, + _content: String, + _replace_all: bool, + ) -> anyhow::Result { + todo!("unused in session handler tests") + } + + async fn multi_patch( + &self, + _path: String, + _edits: Vec, + ) -> anyhow::Result { + todo!("unused in session handler tests") + } + } + + #[async_trait::async_trait] + impl FsReadService for NoopService { + async fn read( + &self, + _path: String, + _start_line: Option, + _end_line: Option, + ) -> anyhow::Result { + todo!("unused in session handler tests") + } + } + + #[async_trait::async_trait] + impl ImageReadService for NoopService { + async fn read_image(&self, _path: String) -> anyhow::Result { + todo!("unused in session handler tests") + } + } + + #[async_trait::async_trait] + impl FsRemoveService for NoopService { + async fn remove(&self, _path: String) -> anyhow::Result { + todo!("unused in session handler tests") + } + } + + #[async_trait::async_trait] + impl FsSearchService for NoopService { + async fn search(&self, _params: forge_domain::FSSearch) -> anyhow::Result> { + Ok(None) + } + } + + #[async_trait::async_trait] + impl FollowUpService for NoopService { + async fn follow_up( + &self, + _question: String, + _options: Vec, + _multiple: Option, + ) -> anyhow::Result> { + Ok(None) + } + } + + #[async_trait::async_trait] + impl FsUndoService for NoopService { + async fn undo(&self, _path: String) -> anyhow::Result { + todo!("unused in session handler tests") + } + } + + #[async_trait::async_trait] + impl NetFetchService for NoopService { + async fn fetch(&self, _url: String, _raw: Option) -> anyhow::Result { + Ok(HttpResponse { + content: String::new(), + code: 200, + context: ResponseContext::Raw, + content_type: "text/plain".to_string(), + }) + } + } + + #[async_trait::async_trait] + impl ShellService for NoopService { + async fn execute( + &self, + _command: String, + _cwd: PathBuf, + _keep_ansi: bool, + _silent: bool, + _env_vars: Option>, + _description: Option, + ) -> anyhow::Result { + Ok(ShellOutput { + output: CommandOutput { + command: String::new(), + stdout: String::new(), + stderr: String::new(), + exit_code: Some(0), + }, + shell: "/bin/zsh".to_string(), + description: None, + }) + } + } + + #[async_trait::async_trait] + impl AuthService for NoopService { + async fn user_info(&self, _api_key: &str) -> anyhow::Result { + Ok(User { + auth_provider_id: AuthProviderId::new("test"), + }) + } + + async fn user_usage(&self, _api_key: &str) -> anyhow::Result { + Ok(UserUsage { + plan: Plan { r#type: "free".to_string() }, + usage: UsageInfo { + current: 0, + limit: 0, + remaining: 0, + reset_in: None, + }, + }) + } + } + + #[async_trait::async_trait] + impl CommandLoaderService for NoopService { + async fn get_commands(&self) -> anyhow::Result> { + Ok(vec![]) + } + } + + #[async_trait::async_trait] + impl PolicyService for NoopService { + async fn check_operation_permission( + &self, + _operation: &forge_domain::PermissionOperation, + ) -> anyhow::Result { + Ok(PolicyDecision { + allowed: true, + path: None, + }) + } + } + + #[async_trait::async_trait] + impl WorkspaceService for NoopService { + async fn sync_workspace( + &self, + _path: PathBuf, + ) -> anyhow::Result>> { + todo!("unused in session handler tests") + } + + async fn query_workspace( + &self, + _path: PathBuf, + _params: SearchParams<'_>, + ) -> anyhow::Result> { + todo!("unused in session handler tests") + } + + async fn list_workspaces(&self) -> anyhow::Result> { + Ok(vec![]) + } + + async fn get_workspace_info(&self, _path: PathBuf) -> anyhow::Result> { + Ok(None) + } + + async fn delete_workspace(&self, _workspace_id: &WorkspaceId) -> anyhow::Result<()> { + Ok(()) + } + + async fn delete_workspaces(&self, _workspace_ids: &[WorkspaceId]) -> anyhow::Result<()> { + Ok(()) + } + + async fn is_indexed(&self, _path: &Path) -> anyhow::Result { + Ok(false) + } + + async fn get_workspace_status(&self, _path: PathBuf) -> anyhow::Result> { + Ok(vec![]) + } + + async fn is_authenticated(&self) -> anyhow::Result { + Ok(false) + } + + async fn init_auth_credentials(&self) -> anyhow::Result { + todo!("unused in session handler tests") + } + + async fn init_workspace(&self, _path: PathBuf) -> anyhow::Result { + todo!("unused in session handler tests") + } + } + + #[async_trait::async_trait] + impl SkillFetchService for NoopService { + async fn fetch_skill(&self, _skill_name: String) -> anyhow::Result { + todo!("unused in session handler tests") + } + + async fn list_skills(&self) -> anyhow::Result> { + Ok(vec![]) + } + } + + impl Services for MockServices { + type ProviderService = MockProviderService; + type AppConfigService = MockConfigService; + type ConversationService = MockConversationService; + type TemplateService = NoopService; + type AttachmentService = NoopService; + type CustomInstructionsService = NoopService; + type FileDiscoveryService = NoopService; + type McpConfigManager = MockMcpConfigService; + type FsWriteService = NoopService; + type PlanCreateService = NoopService; + type FsPatchService = NoopService; + type FsReadService = NoopService; + type ImageReadService = NoopService; + type FsRemoveService = NoopService; + type FsSearchService = NoopService; + type FollowUpService = NoopService; + type FsUndoService = NoopService; + type NetFetchService = NoopService; + type ShellService = NoopService; + type McpService = NoopService; + type AuthService = NoopService; + type AgentRegistry = MockAgentRegistryService; + type CommandLoaderService = NoopService; + type PolicyService = NoopService; + type ProviderAuthService = NoopService; + type WorkspaceService = NoopService; + type SkillFetchService = NoopService; + + fn provider_service(&self) -> &Self::ProviderService { &self.provider_service } + fn config_service(&self) -> &Self::AppConfigService { &self.config_service } + fn conversation_service(&self) -> &Self::ConversationService { &self.conversation_service } + fn template_service(&self) -> &Self::TemplateService { &self.noop_service } + fn attachment_service(&self) -> &Self::AttachmentService { &self.noop_service } + fn file_discovery_service(&self) -> &Self::FileDiscoveryService { &self.noop_service } + fn mcp_config_manager(&self) -> &Self::McpConfigManager { &self.mcp_config_manager } + fn fs_create_service(&self) -> &Self::FsWriteService { &self.noop_service } + fn plan_create_service(&self) -> &Self::PlanCreateService { &self.noop_service } + fn fs_patch_service(&self) -> &Self::FsPatchService { &self.noop_service } + fn fs_read_service(&self) -> &Self::FsReadService { &self.noop_service } + fn image_read_service(&self) -> &Self::ImageReadService { &self.noop_service } + fn fs_remove_service(&self) -> &Self::FsRemoveService { &self.noop_service } + fn fs_search_service(&self) -> &Self::FsSearchService { &self.noop_service } + fn follow_up_service(&self) -> &Self::FollowUpService { &self.noop_service } + fn fs_undo_service(&self) -> &Self::FsUndoService { &self.noop_service } + fn net_fetch_service(&self) -> &Self::NetFetchService { &self.noop_service } + fn shell_service(&self) -> &Self::ShellService { &self.noop_service } + fn mcp_service(&self) -> &Self::McpService { &self.noop_service } + fn custom_instructions_service(&self) -> &Self::CustomInstructionsService { &self.noop_service } + fn auth_service(&self) -> &Self::AuthService { &self.noop_service } + fn agent_registry(&self) -> &Self::AgentRegistry { &self.agent_registry } + fn command_loader_service(&self) -> &Self::CommandLoaderService { &self.noop_service } + fn policy_service(&self) -> &Self::PolicyService { &self.noop_service } + fn provider_auth_service(&self) -> &Self::ProviderAuthService { &self.noop_service } + fn workspace_service(&self) -> &Self::WorkspaceService { &self.noop_service } + fn skill_fetch_service(&self) -> &Self::SkillFetchService { &self.noop_service } + } + + #[tokio::test] + async fn initialize_exposes_acp_capabilities() { + let adapter = AcpAdapter::new_for_test(()); + + let actual = adapter + .handle_initialize(acp::InitializeRequest::new(acp::ProtocolVersion::V1)) + .await + .unwrap(); + + assert_eq!(actual.protocol_version, acp::ProtocolVersion::V1); + assert!(actual.agent_capabilities.load_session); + assert!(actual.agent_capabilities.mcp_capabilities.http); + assert!(actual.agent_capabilities.mcp_capabilities.sse); + } + + #[tokio::test] + async fn authenticate_is_a_no_op() { + let adapter = AcpAdapter::new_for_test(()); + + let actual = adapter + .handle_authenticate(acp::AuthenticateRequest::new(acp::AuthMethodId::new("stdio"))) + .await + .unwrap(); + + assert_eq!(actual, acp::AuthenticateResponse::default()); + } + + #[tokio::test] + async fn cancel_returns_ok_when_session_is_missing() { + let adapter = AcpAdapter::new_for_test(()); + + let actual = adapter + .handle_cancel(acp::CancelNotification::new(acp::SessionId::new("missing"))) + .await; + + assert!(actual.is_ok()); + } + + #[tokio::test] + async fn set_session_mode_updates_state_and_emits_notification() { + let (adapter, mut rx) = AcpAdapter::new_for_test_with_receiver(()); + let session_id = acp::SessionId::new("session-4"); + let conversation_id = ConversationId::generate(); + + adapter + .store_session( + "session-4".to_string(), + SessionState { + conversation_id, + agent_id: AgentId::new("before"), + model_id: None, + cancel_notify: None, + }, + ) + .await; + + let actual = adapter + .handle_set_session_mode(acp::SetSessionModeRequest::new( + session_id.clone(), + acp::SessionModeId::new("after"), + )) + .await; + + assert!(actual.is_ok()); + let state = adapter.session_state("session-4").await.unwrap(); + assert_eq!(state.agent_id, AgentId::new("after")); + + let notification = rx.recv().await; + assert!(notification.is_some()); + let notification = notification.unwrap(); + assert_eq!(notification.session_id, session_id); + } + + #[tokio::test] + async fn new_session_creates_conversation_and_returns_initial_state() { + let services = MockServices::new(); + let adapter = AcpAdapter::new_for_test(services.clone()); + + let actual = adapter + .handle_new_session(acp::NewSessionRequest::new(PathBuf::from("/tmp/project"))) + .await + .unwrap(); + + let conversation_id = ConversationId::parse(actual.session_id.0.as_ref()).unwrap(); + let stored = services.find_conversation(&conversation_id).await.unwrap(); + + assert!(stored.is_some()); + assert_eq!( + actual + .modes + .as_ref() + .map(|modes| modes.current_mode_id.0.as_ref()), + Some("forge") + ); + assert_eq!( + actual + .models + .as_ref() + .map(|models| models.current_model_id.0.as_ref()), + Some("test-model") + ); + let session = adapter.session_state(actual.session_id.0.as_ref()).await.unwrap(); + assert_eq!(session.agent_id, AgentId::new("forge")); + } + + #[tokio::test] + async fn load_session_returns_invalid_params_for_unknown_conversation() { + let services = MockServices::new(); + let adapter = AcpAdapter::new_for_test(services); + + let actual = adapter + .handle_load_session(acp::LoadSessionRequest::new( + acp::SessionId::new(ConversationId::generate().into_string()), + PathBuf::from("/tmp/project"), + )) + .await; + + assert!(actual.is_err()); + assert_eq!(actual.unwrap_err().code, acp::ErrorCode::InvalidParams); + } + + #[tokio::test] + async fn load_session_uses_existing_conversation_and_builds_state() { + let services = MockServices::new(); + let adapter = AcpAdapter::new_for_test(services.clone()); + let conversation = Conversation::generate(); + let conversation_id = conversation.id; + services.insert_conversation(conversation); + + let actual = adapter + .handle_load_session(acp::LoadSessionRequest::new( + acp::SessionId::new(conversation_id.into_string()), + PathBuf::from("/tmp/project"), + )) + .await + .unwrap(); + + assert_eq!( + actual + .modes + .as_ref() + .map(|modes| modes.current_mode_id.0.as_ref()), + Some("forge") + ); + assert_eq!( + actual + .models + .as_ref() + .map(|models| models.current_model_id.0.as_ref()), + Some("test-model") + ); + let session = adapter + .session_state(conversation_id.into_string().as_str()) + .await + .unwrap(); + assert_eq!(session.conversation_id, conversation_id); + } + + #[tokio::test] + async fn set_session_model_updates_session_and_config() { + let (adapter, mut rx) = AcpAdapter::new_for_test_with_receiver(MockServices::new()); + let conversation = Conversation::generate(); + let session_id = acp::SessionId::new(conversation.id.into_string()); + + adapter + .store_session( + session_id.0.as_ref().to_string(), + SessionState { + conversation_id: conversation.id, + agent_id: AgentId::new("forge"), + model_id: None, + cancel_notify: None, + }, + ) + .await; + + let actual = adapter + .handle_set_session_model(acp::SetSessionModelRequest::new( + session_id.clone(), + acp::ModelId::new("gpt-test"), + )) + .await; + + assert!(actual.is_ok()); + let session = adapter.session_state(session_id.0.as_ref()).await.unwrap(); + assert_eq!(session.model_id, Some(ModelId::new("gpt-test"))); + + let updates = adapter.services.config_updates(); + assert_eq!( + updates, + vec![vec![ConfigOperation::SetSessionConfig(ModelConfig::new( + ProviderId::OPENAI, + ModelId::new("gpt-test"), + ))]] + ); + + let notification = rx.recv().await.expect("expected model change notification"); + assert_eq!(notification.session_id, session_id); + } +} diff --git a/crates/forge_app/src/acp/state_builders.rs b/crates/forge_app/src/acp/state_builders.rs new file mode 100644 index 0000000000..a0fe49cd44 --- /dev/null +++ b/crates/forge_app/src/acp/state_builders.rs @@ -0,0 +1,306 @@ +use std::collections::BTreeMap; +use std::sync::Arc; + +use agent_client_protocol as acp; +use forge_domain::{ + Agent, AgentId, McpHttpServer, McpOAuthSetting, McpServerConfig, Scope, ServerName, +}; + +use crate::{ + AgentProviderResolver, AgentRegistry, McpConfigManager, McpService, ProviderAuthService, + ProviderService, Services, +}; + +use super::conversion; +use super::error::{Error, Result}; + +/// Maximum allowed length for an MCP server name (prevents injection). +const MAX_SERVER_NAME_LEN: usize = 128; + +pub(super) struct StateBuilders; + +impl StateBuilders { + pub(super) async fn build_session_mode_state( + services: &S, + current_agent_id: &AgentId, + ) -> Result { + let agents = services + .agent_registry() + .get_agents() + .await + .map_err(Error::Application)?; + + Ok(conversion::build_session_mode_state( + &agents, + current_agent_id, + )) + } + + pub(super) async fn build_session_model_state( + services: &Arc, + current_agent: &Agent, + ) -> Result { + let agent_provider_resolver = AgentProviderResolver::new(services.clone()); + let provider = agent_provider_resolver + .get_provider(Some(current_agent.id.clone())) + .await + .map_err(Error::Application)?; + let provider = services + .provider_auth_service() + .refresh_provider_credential(provider) + .await + .map_err(Error::Application)?; + + let mut models = services + .provider_service() + .models(provider) + .await + .map_err(Error::Application)?; + models.sort_by(|left, right| left.name.cmp(&right.name)); + + let available_models = models + .iter() + .map(|model| { + let mut model_info = acp::ModelInfo::new( + model.id.to_string(), + model.name.clone().unwrap_or_else(|| model.id.to_string()), + ) + .description(model.description.clone()); + + let mut meta = serde_json::Map::new(); + if let Some(context_length) = model.context_length { + meta.insert( + "contextLength".to_string(), + serde_json::json!(context_length), + ); + } + if let Some(tools_supported) = model.tools_supported { + meta.insert( + "toolsSupported".to_string(), + serde_json::json!(tools_supported), + ); + } + if let Some(supports_reasoning) = model.supports_reasoning { + meta.insert( + "supportsReasoning".to_string(), + serde_json::json!(supports_reasoning), + ); + } + if !model.input_modalities.is_empty() { + let modalities = model + .input_modalities + .iter() + .map(|modality| format!("{:?}", modality).to_lowercase()) + .collect::>(); + meta.insert("inputModalities".to_string(), serde_json::json!(modalities)); + } + if !meta.is_empty() { + model_info = model_info.meta(meta); + } + + model_info + }) + .collect(); + + Ok( + acp::SessionModelState::new(current_agent.model.to_string(), available_models).meta({ + let mut meta = serde_json::Map::new(); + meta.insert("searchable".to_string(), serde_json::json!(true)); + meta.insert("searchThreshold".to_string(), serde_json::json!(10)); + meta.insert("filterable".to_string(), serde_json::json!(true)); + meta.insert("groupBy".to_string(), serde_json::json!("provider")); + meta + }), + ) + } + + /// Loads MCP server configurations provided by the ACP client. + /// + /// # Trust model + /// + /// The stdio transport inherits OS-level process isolation, so the + /// client is the parent process (Acepe). Server names are validated + /// to prevent injection. The configs are written to the local scope + /// only and do not persist across Forge restarts unless the caller + /// explicitly saves them. + pub(super) async fn load_mcp_servers( + services: &S, + mcp_servers: &[acp::McpServer], + ) -> Result<()> { + let mut config = services + .mcp_config_manager() + .read_mcp_config(Some(&Scope::Local)) + .await + .map_err(Error::Application)?; + + let server_names: Vec = mcp_servers + .iter() + .filter_map(|s| { + match Self::acp_to_mcp_server_config(s) { + Ok((name, server_config)) => { + config.mcp_servers.insert(name.clone(), server_config); + Some(name.to_string()) + } + Err(error) => { + tracing::warn!("Skipping invalid MCP server config: {}", error); + None + } + } + }) + .collect(); + + tracing::info!("Loading {} MCP servers from ACP client: {:?}", server_names.len(), server_names); + + services + .mcp_config_manager() + .write_mcp_config(&config, &Scope::Local) + .await + .map_err(Error::Application)?; + services.mcp_service().reload_mcp().await.map_err(Error::Application)?; + Ok(()) + } + + fn acp_to_mcp_server_config(server: &acp::McpServer) -> Result<(ServerName, McpServerConfig)> { + match server { + acp::McpServer::Stdio(stdio) => { + Self::validate_server_name(&stdio.name)?; + let env = stdio + .env + .iter() + .map(|entry| (entry.name.clone(), entry.value.clone())) + .collect::>(); + Ok(( + ServerName::from(stdio.name.clone()), + McpServerConfig::new_stdio(stdio.command.to_string_lossy().to_string(), stdio.args.clone(), Some(env)), + )) + } + acp::McpServer::Http(http) => { + Self::validate_server_name(&http.name)?; + Ok(( + ServerName::from(http.name.clone()), + McpServerConfig::Http(McpHttpServer { + url: http.url.clone(), + headers: http + .headers + .iter() + .map(|header| (header.name.clone(), header.value.clone())) + .collect(), + timeout: None, + disable: false, + oauth: McpOAuthSetting::AutoDetect, + }), + )) + } + acp::McpServer::Sse(sse) => { + Self::validate_server_name(&sse.name)?; + Ok(( + ServerName::from(sse.name.clone()), + McpServerConfig::Http(McpHttpServer { + url: sse.url.clone(), + headers: sse + .headers + .iter() + .map(|header| (header.name.clone(), header.value.clone())) + .collect(), + timeout: None, + disable: false, + oauth: McpOAuthSetting::AutoDetect, + }), + )) + } + _ => Err(Error::Application(anyhow::anyhow!( + "Unsupported MCP server type" + ))), + } + } + + /// Validates that an MCP server name is safe to use as a config key. + fn validate_server_name(name: &str) -> Result<()> { + if name.is_empty() { + return Err(Error::Application(anyhow::anyhow!( + "MCP server name must not be empty" + ))); + } + if name.len() > MAX_SERVER_NAME_LEN { + return Err(Error::Application(anyhow::anyhow!( + "MCP server name exceeds maximum length of {} characters", + MAX_SERVER_NAME_LEN + ))); + } + // Only allow alphanumeric, hyphens, underscores, and dots. + if !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.') { + return Err(Error::Application(anyhow::anyhow!( + "MCP server name '{}' contains invalid characters (allowed: alphanumeric, -, _, .)", + name + ))); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use agent_client_protocol as acp; + use agent_client_protocol::{EnvVariable, HttpHeader}; + use forge_domain::{McpOAuthSetting, McpServerConfig}; + + use super::StateBuilders; + + #[test] + fn maps_stdio_servers_with_env() { + let server = acp::McpServer::Stdio( + acp::McpServerStdio::new("local-server", "/bin/echo") + .args(vec!["hello".to_string()]) + .env(vec![EnvVariable::new("TOKEN", "secret")]), + ); + + let (name, config) = StateBuilders::acp_to_mcp_server_config(&server).unwrap(); + + assert_eq!(name.to_string(), "local-server"); + match config { + McpServerConfig::Stdio(stdio) => { + assert_eq!(stdio.command, "/bin/echo"); + assert_eq!(stdio.args, vec!["hello".to_string()]); + assert_eq!(stdio.env.get("TOKEN"), Some(&"secret".to_string())); + } + McpServerConfig::Http(_) => panic!("expected stdio config"), + } + } + + #[test] + fn maps_http_servers_with_auto_detect_oauth() { + let server = acp::McpServer::Http( + acp::McpServerHttp::new("remote.server", "https://example.com/mcp").headers(vec![ + HttpHeader::new("Authorization", "Bearer token"), + ]), + ); + + let (name, config) = StateBuilders::acp_to_mcp_server_config(&server).unwrap(); + + assert_eq!(name.to_string(), "remote.server"); + match config { + McpServerConfig::Http(http) => { + assert_eq!(http.url, "https://example.com/mcp"); + assert_eq!( + http.headers.get("Authorization"), + Some(&"Bearer token".to_string()) + ); + assert_eq!(http.oauth, McpOAuthSetting::AutoDetect); + } + McpServerConfig::Stdio(_) => panic!("expected http config"), + } + } + + #[test] + fn rejects_invalid_server_names() { + let server = acp::McpServer::Sse(acp::McpServerSse::new( + "bad server name!", + "https://example.com/sse", + )); + + let error = StateBuilders::acp_to_mcp_server_config(&server).unwrap_err(); + let actual = error.to_string(); + + assert!(actual.contains("invalid characters")); + } +} diff --git a/crates/forge_app/src/acp_app.rs b/crates/forge_app/src/acp_app.rs new file mode 100644 index 0000000000..7f4c07471a --- /dev/null +++ b/crates/forge_app/src/acp_app.rs @@ -0,0 +1,119 @@ +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Result; +use forge_config::ForgeConfig; + +use crate::{EnvironmentInfra, Services}; + +/// ACP (Agent Communication Protocol) application orchestrator. +pub struct AcpApp { + services: Arc, +} + +/// Maximum time to wait for ACP I/O before considering the client hung. +const IO_TIMEOUT: Duration = Duration::from_secs(300); + +/// Maximum time to wait for pending notifications to drain on shutdown. +const SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(5); + +impl> AcpApp { + /// Creates a new ACP application orchestrator. + pub fn new(services: Arc) -> Self { + Self { services } + } + + /// Starts the ACP server over stdio transport. + /// + /// # Trust model + /// + /// The stdio transport inherits OS-level process isolation: only the + /// parent process (e.g. Acepe) that spawned `forge machine stdio` can + /// read/write the stdin/stdout pipes. No network listener is opened. + /// Authentication is therefore a no-op by design. + pub async fn start_stdio(&self) -> Result<()> { + use agent_client_protocol as acp; + use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; + + let services = self.services.clone(); + let handle = tokio::task::spawn_blocking(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| anyhow::anyhow!("Failed to create Tokio runtime: {}", e))?; + + rt.block_on(async move { + let (adapter, mut rx) = crate::acp::AcpAdapter::new(services); + let adapter = Arc::new(adapter); + + let local_set = tokio::task::LocalSet::new(); + local_set + .run_until(async move { + let outgoing = tokio::io::stdout().compat_write(); + let incoming = tokio::io::stdin().compat(); + + let (conn, handle_io) = acp::AgentSideConnection::new( + adapter.clone(), + outgoing, + incoming, + |fut| { + tokio::task::spawn_local(fut); + }, + ); + + let conn = Arc::new(conn); + adapter.set_client_connection(conn.clone()).await; + + let conn_for_notifications = conn.clone(); + let notification_task = tokio::task::spawn_local(async move { + while let Some(session_notification) = rx.recv().await { + use agent_client_protocol::Client; + + if let Err(error) = conn_for_notifications + .session_notification(session_notification) + .await + { + tracing::error!( + "Failed to send session notification: {}", + error + ); + break; + } + } + }); + + // Wait for I/O with a timeout to prevent indefinite hangs + // when the client stalls. + let io_result = match tokio::time::timeout(IO_TIMEOUT, handle_io).await { + Ok(result) => result, + Err(_) => { + tracing::warn!("ACP I/O timed out after {:?}", IO_TIMEOUT); + notification_task.abort(); + return Err(anyhow::anyhow!( + "ACP transport timed out after {:?}", + IO_TIMEOUT + )); + } + }; + + // Graceful shutdown: give the notification task time to + // drain pending messages instead of aborting immediately. + drop(adapter); // drops the sender half → rx.recv() returns None + let _ = tokio::time::timeout(SHUTDOWN_DRAIN_TIMEOUT, notification_task).await; + + io_result.map_err(|error| anyhow::anyhow!("ACP transport error: {}", error)) + }) + .await + }) + }); + + match handle.await { + Ok(result) => result, + Err(error) if error.is_cancelled() => { + tracing::info!("ACP server task was cancelled"); + Ok(()) + } + Err(error) => Err(anyhow::anyhow!("ACP server task panicked: {}", error)), + } + } +} diff --git a/crates/forge_app/src/lib.rs b/crates/forge_app/src/lib.rs index 66de3e618d..6156e316e8 100644 --- a/crates/forge_app/src/lib.rs +++ b/crates/forge_app/src/lib.rs @@ -1,3 +1,5 @@ +mod acp; +mod acp_app; mod agent; mod agent_executor; mod agent_provider_resolver; @@ -39,6 +41,7 @@ pub mod utils; mod walker; mod workspace_status; +pub use acp_app::*; pub use agent::*; pub use agent_provider_resolver::*; pub use app::*; diff --git a/crates/forge_main/src/acp_runner.rs b/crates/forge_main/src/acp_runner.rs new file mode 100644 index 0000000000..b00cbeab3e --- /dev/null +++ b/crates/forge_main/src/acp_runner.rs @@ -0,0 +1,62 @@ +use std::future::Future; + +use anyhow::Result; +use forge_api::API; + +/// Abstraction over the ACP stdio transport entry point for testability. +pub trait MachineStdioApi { + fn acp_start_stdio(&self) -> impl Future> + Send; +} + +impl MachineStdioApi for T { + fn acp_start_stdio(&self) -> impl Future> + Send { + API::acp_start_stdio(self) + } +} + +/// Starts the ACP machine stdio server by delegating to the provided API. +pub async fn run_machine_stdio_server(api: &A) -> Result<()> { + api.acp_start_stdio().await +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, atomic::{AtomicBool, Ordering}}; + + use anyhow::Result; + + use super::{MachineStdioApi, run_machine_stdio_server}; + + struct MockApi { + called: Arc, + } + + impl MockApi { + fn new(called: Arc) -> Self { + Self { called } + } + } + + impl MachineStdioApi for MockApi { + fn acp_start_stdio(&self) -> impl std::future::Future> + Send { + let called = self.called.clone(); + async move { + called.store(true, Ordering::SeqCst); + Ok(()) + } + } + } + + #[tokio::test] + async fn test_run_machine_stdio_server_delegates_to_api_transport() -> Result<()> { + let called = Arc::new(AtomicBool::new(false)); + let fixture = MockApi::new(called.clone()); + + run_machine_stdio_server(&fixture).await?; + + let actual = called.load(Ordering::SeqCst); + let expected = true; + assert_eq!(actual, expected); + Ok(()) + } +} diff --git a/crates/forge_main/src/cli.rs b/crates/forge_main/src/cli.rs index a4c859bcd7..8c9f246d3e 100644 --- a/crates/forge_main/src/cli.rs +++ b/crates/forge_main/src/cli.rs @@ -82,6 +82,9 @@ pub enum TopLevelCommand { /// Manage agents. Agent(AgentCommandGroup), + /// Run machine-oriented commands. + Machine(MachineCommandGroup), + /// Generate shell extension scripts. #[command(subcommand, alias = "extension")] Zsh(ZshCommandGroup), @@ -301,6 +304,20 @@ pub enum AgentCommand { List, } +/// Command group for machine-oriented interfaces. +#[derive(Parser, Debug, Clone)] +pub struct MachineCommandGroup { + #[command(subcommand)] + pub command: MachineCommand, +} + +/// Machine-oriented subcommands for non-interactive transport protocols. +#[derive(Subcommand, Debug, Clone)] +pub enum MachineCommand { + /// Run the machine interface over stdio. + Stdio, +} + /// Command group for workspace management. #[derive(Parser, Debug, Clone)] pub struct WorkspaceCommandGroup { @@ -2023,4 +2040,25 @@ mod tests { }; assert!(!actual); } + + #[test] + fn test_machine_stdio_command() { + let fixture = Cli::parse_from(["forge", "machine", "stdio"]); + let actual = matches!( + fixture.subcommands, + Some(TopLevelCommand::Machine(MachineCommandGroup { + command: MachineCommand::Stdio, + })) + ); + let expected = true; + assert_eq!(actual, expected); + } + + #[test] + fn test_machine_stdio_is_not_interactive() { + let fixture = Cli::parse_from(["forge", "machine", "stdio"]); + let actual = fixture.is_interactive(); + let expected = false; + assert_eq!(actual, expected); + } } diff --git a/crates/forge_main/src/lib.rs b/crates/forge_main/src/lib.rs index 960f0f16b1..0cb68db544 100644 --- a/crates/forge_main/src/lib.rs +++ b/crates/forge_main/src/lib.rs @@ -1,4 +1,5 @@ pub mod banner; +mod acp_runner; mod cli; mod completer; mod conversation_selector; diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 9ecd50fc41..d20bbeab13 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -473,6 +473,14 @@ impl A + Send + Sync> UI } return Ok(()); } + TopLevelCommand::Machine(machine_group) => { + match machine_group.command { + crate::cli::MachineCommand::Stdio => { + crate::acp_runner::run_machine_stdio_server(self.api.as_ref()).await?; + return Ok(()); + } + } + } TopLevelCommand::List(list_group) => { let porcelain = list_group.porcelain; match list_group.command {