diff --git a/.release-plz.toml b/.release-plz.toml index 5e113808..49945006 100644 --- a/.release-plz.toml +++ b/.release-plz.toml @@ -16,6 +16,10 @@ name = "agent-client-protocol" git_tag_name = "v{{ version }}" version_group = "acp" +# Experimental HTTP/WebSocket transport; keep independent versioning for now. +[[package]] +name = "agent-client-protocol-http" + [[package]] name = "agent-client-protocol-derive" version_group = "acp" diff --git a/Cargo.lock b/Cargo.lock index 66d276de..98a3c392 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,6 +76,28 @@ dependencies = [ "syn", ] +[[package]] +name = "agent-client-protocol-http" +version = "0.1.0" +dependencies = [ + "agent-client-protocol", + "agent-client-protocol-test", + "async-stream", + "async-tungstenite", + "axum", + "eventsource-stream", + "futures", + "reqwest", + "serde_json", + "thiserror", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] + [[package]] name = "agent-client-protocol-polyfill" version = "0.14.0" @@ -222,7 +244,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -233,7 +255,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -269,7 +291,7 @@ dependencies = [ "polling", "rustix", "slab", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -316,7 +338,7 @@ dependencies = [ "rustix", "signal-hook-registry", "slab", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -358,6 +380,25 @@ dependencies = [ "syn", ] +[[package]] +name = "async-tungstenite" +version = "0.34.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8447f02eaa65412035e2d3eeaa3fc82bbb8d7137c84c5976b4af685136012ee9" +dependencies = [ + "atomic-waker", + "futures-core", + "futures-io", + "futures-task", + "log", + "pin-project-lite", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -370,6 +411,28 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "axum" version = "0.8.9" @@ -377,6 +440,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", + "axum-macros", + "base64", "bytes", "form_urlencoded", "futures-util", @@ -395,8 +460,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -422,6 +489,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "base64" version = "0.22.1" @@ -434,6 +512,15 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "blocking" version = "1.6.2" @@ -475,6 +562,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -544,12 +633,31 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -568,18 +676,47 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "darling" version = "0.23.0" @@ -614,6 +751,12 @@ dependencies = [ "syn", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "deranged" version = "0.5.8" @@ -646,6 +789,16 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -663,6 +816,12 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aeda16ab4059c5fd2a83f2b9c9e9c981327b18aa8e3b313f7e6563799d4f093e" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -682,7 +841,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -706,6 +865,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "eventsource-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" +dependencies = [ + "futures-core", + "nom", + "pin-project-lite", +] + [[package]] name = "expect-test" version = "1.5.1" @@ -749,6 +919,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.32" @@ -863,6 +1039,43 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -871,7 +1084,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -962,9 +1175,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.10.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -981,6 +1194,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1203,6 +1431,65 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[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", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.99" @@ -1245,21 +1532,18 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "log" version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -1287,15 +1571,21 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" -version = "1.2.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1310,13 +1600,23 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1358,33 +1658,16 @@ dependencies = [ ] [[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" +name = "openssl-probe" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] -name = "parking_lot_core" -version = "0.9.12" +name = "parking" +version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "pastey" @@ -1452,7 +1735,7 @@ dependencies = [ "hermit-abi", "pin-project-lite", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1470,6 +1753,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1504,28 +1796,110 @@ dependencies = [ ] [[package]] -name = "quote" -version = "1.0.45" +name = "quinn" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ - "proc-macro2", + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", ] [[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "redox_syscall" -version = "0.5.18" +name = "quinn-proto" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ - "bitflags", -] + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] [[package]] name = "ref-cast" @@ -1590,15 +1964,21 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -1610,6 +1990,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rmcp" version = "1.7.0" @@ -1675,7 +2069,82 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -1690,6 +2159,24 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.9.0" @@ -1729,10 +2216,27 @@ dependencies = [ ] [[package]] -name = "scopeguard" -version = "1.2.0" +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] [[package]] name = "semver" @@ -1850,6 +2354,17 @@ dependencies = [ "syn", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1881,6 +2396,22 @@ dependencies = [ "libc", ] +[[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 = "slab" version = "0.4.12" @@ -1900,7 +2431,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1958,6 +2489,12 @@ dependencies = [ "syn", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -2082,12 +2619,11 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2101,6 +2637,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -2112,6 +2658,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -2253,6 +2811,30 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2261,9 +2843,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.3" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-xid" @@ -2271,6 +2853,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -2301,7 +2889,7 @@ version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ - "getrandom", + "getrandom 0.4.2", "js-sys", "wasm-bindgen", ] @@ -2312,6 +2900,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "vte" version = "0.14.1" @@ -2321,6 +2915,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2466,6 +3070,43 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows" version = "0.62.2" @@ -2567,6 +3208,24 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2576,6 +3235,39 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "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", + "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.2.1" @@ -2585,6 +3277,102 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +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 = "wit-bindgen" version = "0.51.0" @@ -2708,6 +3496,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -2729,6 +3537,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/Cargo.toml b/Cargo.toml index 480fda39..130ccd14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "src/agent-client-protocol-polyfill", "src/agent-client-protocol-rmcp", "src/agent-client-protocol-conductor", + "src/agent-client-protocol-http", "src/agent-client-protocol-test", "src/agent-client-protocol-trace-viewer", "src/yopo", @@ -28,6 +29,7 @@ homepage = "https://github.com/agentclientprotocol/rust-sdk" agent-client-protocol = { path = "src/agent-client-protocol", version = "0.14.0" } agent-client-protocol-conductor = { path = "src/agent-client-protocol-conductor", version = "0.14.0" } agent-client-protocol-derive = { path = "src/agent-client-protocol-derive", version = "0.14.0" } +agent-client-protocol-http = { path = "src/agent-client-protocol-http", version = "0.1.0" } agent-client-protocol-polyfill = { path = "src/agent-client-protocol-polyfill", version = "0.14.0" } agent-client-protocol-rmcp = { path = "src/agent-client-protocol-rmcp", version = "0.14.0" } agent-client-protocol-test = { path = "src/agent-client-protocol-test" } @@ -38,8 +40,9 @@ yopo = { package = "agent-client-protocol-yopo", path = "src/yopo" } agent-client-protocol-schema = { version = "=0.13.6", features = ["tracing"] } # Core async runtime -tokio = { version = "1.52", features = ["full"] } +tokio = { version = "1.52", default-features = false } tokio-util = { version = "0.7", features = ["compat"] } +async-tungstenite = { version = "0.34.1", default-features = false, features = ["tokio-rustls-webpki-roots"] } # Serialization serde = { version = "1.0", features = ["derive", "rc"] } @@ -63,6 +66,9 @@ clap = { version = "4.5", features = ["derive"] } # HTTP axum = "0.8" +reqwest = { version = "0.13", default-features = false, features = ["rustls", "json"] } +eventsource-stream = "0.2" +url = "2.5" async-process = "2" async-stream = "0.3.6" blocking = "1" @@ -73,6 +79,7 @@ open = "5" rustc-hash = "2.1.1" shell-words = "1.1" strip-ansi-escapes = "0.2" +tower-http = "0.6" # Proc macros diff --git a/deny.toml b/deny.toml index 9e0d7fc2..acde6c96 100644 --- a/deny.toml +++ b/deny.toml @@ -90,6 +90,7 @@ ignore = [ # [possible values: any SPDX 3.11 short identifier (+ optional exception)]. allow = [ "MIT", + "ISC", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", "Unicode-3.0", diff --git a/md/SUMMARY.md b/md/SUMMARY.md index d5cf4344..9827501a 100644 --- a/md/SUMMARY.md +++ b/md/SUMMARY.md @@ -9,6 +9,10 @@ - [Request Cancellation](./request-cancellation.md) - [Protocol V2](./protocol-v2.md) +# Transports + +- [HTTP / WebSocket Transport](./http-transport.md) + # Conductor (agent-client-protocol-conductor) - [Conductor Design](./conductor.md) diff --git a/md/http-transport.md b/md/http-transport.md new file mode 100644 index 00000000..64d75b17 --- /dev/null +++ b/md/http-transport.md @@ -0,0 +1,108 @@ +# HTTP / WebSocket Transport + +`agent-client-protocol-http` exposes ACP agents over one `/acp` endpoint. + +- `POST /acp` with `initialize` creates a connection and returns `Acp-Connection-Id`. +- Later `POST /acp` requests include `Acp-Connection-Id`; session-scoped requests also include `Acp-Session-Id` or `params.sessionId`. +- `GET /acp` with `Accept: text/event-stream` streams agent messages over SSE. Use a connection-level stream for connection-scoped messages and per-session streams for session-scoped messages. +- `GET /acp` with a WebSocket upgrade uses text frames for JSON-RPC messages. +- `DELETE /acp` tears down the connection. + +`POST /acp` request bodies are limited to 16 MiB. + +## HTTP + SSE Streams + +After `initialize`, clients should open a connection-level SSE stream: + +- `GET /acp` +- `Accept: text/event-stream` +- `Acp-Connection-Id: ` +- no `Acp-Session-Id` + +This stream carries connection-scoped messages. + +Session-scoped messages are routed to session-specific SSE streams. For each +active session, clients should also open: + +- `GET /acp` +- `Accept: text/event-stream` +- `Acp-Connection-Id: ` +- `Acp-Session-Id: ` + +Open a session stream before sending methods such as `session/prompt`, +`session/load`, `session/resume`, or other session-scoped requests. When a +`session/new` or `session/fork` response returns a new `sessionId`, open an SSE +stream for that returned session before expecting updates or responses for it. + +## Features + +The crate does not enable either transport side by default. Opt into only the side(s) you need. + +```toml +agent-client-protocol-http = { version = "...", features = ["client"] } +agent-client-protocol-http = { version = "...", features = ["server"] } +agent-client-protocol-http = { version = "...", features = ["client", "server"] } +``` + +The `client` feature exposes `HttpClient`. The `server` feature exposes +`AcpHttpServer`, `ServerOptions`, and `CorsOptions`. + +## Request Cancellation + +Request cancellation is available when the transport crate forwards the core SDK +feature: + +```toml +agent-client-protocol-http = { version = "...", features = ["client", "server", "unstable_cancel_request"] } +``` + +`$/cancel_request` is connection-scoped. The HTTP transport does not apply +`Acp-Session-Id` to cancellation notifications, and routes outgoing +cancellation notifications over the connection stream rather than a session +stream. + +WebSocket connections can carry cancellation at any point after the socket is +open. With HTTP + SSE, cancellation can be sent after `initialize` completes and +the client has received `Acp-Connection-Id`; an in-flight `initialize` request +cannot be cancelled with a hop-local `$/cancel_request` on this transport shape. + +## Server + +```rust +use agent_client_protocol_http::AcpHttpServer; + +let app = AcpHttpServer::new(|| my_agent()).into_router(); +let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await?; +axum::serve(listener, app).await?; +``` + +Cross-origin browser access is disabled by default. Enable it by allowlisting +the browser origins that should be able to access the ACP endpoint: + +```rust +use agent_client_protocol_http::{AcpHttpServer, CorsOptions, ServerOptions}; + +let app = AcpHttpServer::new(|| my_agent()) + .with_options(ServerOptions { + cors: CorsOptions::allow_origins(["http://localhost:5173"])?, + ..ServerOptions::default() + }) + .into_router(); +``` + +## Client + +```rust +use agent_client_protocol_http::HttpClient; + +let transport = HttpClient::new("http://127.0.0.1:8080")?; +my_client().connect_to(transport).await?; +``` + +The same `HttpClient` also speaks WebSocket — pass a `ws://` or `wss://` URL +and it will open a single bidirectional connection instead of using POST + SSE: + +```rust +let transport = HttpClient::new("ws://127.0.0.1:8080")?; +my_client().connect_to(transport).await?; +``` diff --git a/md/introduction.md b/md/introduction.md index 220bdc54..dcced41f 100644 --- a/md/introduction.md +++ b/md/introduction.md @@ -22,6 +22,7 @@ The `agent-client-protocol` crate includes a [`concepts`](https://docs.rs/agent- src/ ├── agent-client-protocol/ # Core protocol SDK ├── agent-client-protocol-tokio/ # Tokio utilities (process spawning) +├── agent-client-protocol-http/ # HTTP/SSE/WebSocket transport ├── agent-client-protocol-rmcp/ # Integration with rmcp crate ├── agent-client-protocol-cookbook/ # Usage patterns (rendered as rustdoc) ├── agent-client-protocol-derive/ # Proc macros @@ -37,11 +38,13 @@ src/ graph TD acp[agent-client-protocol
Core SDK] tokio[agent-client-protocol-tokio
Process spawning] + http[agent-client-protocol-http
HTTP/SSE/WebSocket transport] rmcp[agent-client-protocol-rmcp
rmcp integration] conductor[agent-client-protocol-conductor
Proxy orchestration] cookbook[agent-client-protocol-cookbook
Usage patterns] tokio --> acp + http --> acp rmcp --> acp conductor --> acp conductor --> tokio diff --git a/src/agent-client-protocol-conductor/Cargo.toml b/src/agent-client-protocol-conductor/Cargo.toml index f31ec43c..67f4e157 100644 --- a/src/agent-client-protocol-conductor/Cargo.toml +++ b/src/agent-client-protocol-conductor/Cargo.toml @@ -33,7 +33,7 @@ rustc-hash.workspace = true serde.workspace = true serde_json.workspace = true strip-ansi-escapes.workspace = true -tokio.workspace = true +tokio = { workspace = true, features = ["fs", "io-util", "macros", "rt", "rt-multi-thread", "sync", "time"] } tokio-util.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/src/agent-client-protocol-http/CHANGELOG.md b/src/agent-client-protocol-http/CHANGELOG.md new file mode 100644 index 00000000..1a588142 --- /dev/null +++ b/src/agent-client-protocol-http/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +## [Unreleased] diff --git a/src/agent-client-protocol-http/Cargo.toml b/src/agent-client-protocol-http/Cargo.toml new file mode 100644 index 00000000..7b2506d0 --- /dev/null +++ b/src/agent-client-protocol-http/Cargo.toml @@ -0,0 +1,78 @@ +[package] +name = "agent-client-protocol-http" +version = "0.1.0" +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "HTTP and WebSocket transport for the Agent Client Protocol (ACP)" +keywords = ["acp", "agent", "protocol", "http", "websocket"] +categories = ["development-tools", "web-programming::http-server"] + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[features] +default = [] +client = [ + "dep:agent-client-protocol", + "dep:eventsource-stream", + "dep:futures", + "dep:reqwest", + "dep:serde_json", + "dep:thiserror", + "dep:async-tungstenite", + "dep:tokio", + "tokio/rt", + "dep:tracing", + "dep:url", +] +server = [ + "dep:agent-client-protocol", + "dep:async-stream", + "dep:axum", + "dep:futures", + "dep:serde_json", + "dep:tokio", + "tokio/macros", + "tokio/rt", + "tokio/sync", + "dep:tower-http", + "dep:tracing", + "dep:uuid", +] +unstable_cancel_request = ["agent-client-protocol?/unstable_cancel_request"] + +[dependencies] +agent-client-protocol = { workspace = true, optional = true } + +futures = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } +thiserror = { workspace = true, optional = true } +tokio = { workspace = true, optional = true } +tracing = { workspace = true, optional = true } + +# Server +async-stream = { workspace = true, optional = true } +axum = { workspace = true, features = ["ws", "macros"], optional = true } +tower-http = { workspace = true, features = ["cors"], optional = true } +uuid = { workspace = true, optional = true } + +# Client +async-tungstenite = { workspace = true, optional = true } +eventsource-stream = { workspace = true, optional = true } +reqwest = { workspace = true, features = ["stream"], optional = true } +url = { workspace = true, optional = true } + +[dev-dependencies] +agent-client-protocol-test.workspace = true +async-stream.workspace = true +axum = { workspace = true, features = ["ws", "macros"] } +tokio = { workspace = true, features = ["macros", "net", "rt", "sync", "time"] } +async-tungstenite.workspace = true +tracing-subscriber.workspace = true + +[lints] +workspace = true diff --git a/src/agent-client-protocol-http/README.md b/src/agent-client-protocol-http/README.md new file mode 100644 index 00000000..30f910ed --- /dev/null +++ b/src/agent-client-protocol-http/README.md @@ -0,0 +1,22 @@ +# agent-client-protocol-http + +HTTP/WebSocket transport for ACP agents. + +- **Server**: `AcpHttpServer` exposes agents over HTTP + SSE with optional WebSocket upgrade +- **Client**: `HttpClient` connects to remote agents over HTTP + SSE + +The crate does not enable either transport side by default. Opt into the +surface you need: + +```toml +agent-client-protocol-http = { version = "...", features = ["client"] } +agent-client-protocol-http = { version = "...", features = ["server"] } +``` + +Cross-origin browser access is disabled by default. Configure `ServerOptions` +with `CorsOptions::allow_origins(...)` to allow specific browser origins. + +Enable the `unstable_cancel_request` feature to forward core SDK request +cancellation support through this transport. + +See the [documentation](https://docs.rs/agent-client-protocol-http) for usage examples. diff --git a/src/agent-client-protocol-http/src/client.rs b/src/agent-client-protocol-http/src/client.rs new file mode 100644 index 00000000..8bb92340 --- /dev/null +++ b/src/agent-client-protocol-http/src/client.rs @@ -0,0 +1,1962 @@ +use std::{ + collections::{HashMap, HashSet, VecDeque}, + sync::{Arc, Mutex as StdMutex}, +}; + +use agent_client_protocol::{ + Agent, Channel, Client, ConnectTo, Error as AcpError, RawJsonRpcMessage, + schema::{RequestId, Response as RpcResponse}, +}; +use async_tungstenite::tungstenite::Message as WsMessage; +use futures::{ + StreamExt, + channel::mpsc::{self, UnboundedSender}, + future::{BoxFuture, FutureExt}, + pin_mut, + stream::FuturesUnordered, +}; +use thiserror::Error; +use tracing::{debug, error, trace, warn}; + +use crate::protocol::{ + HEADER_CONNECTION_ID, HEADER_SESSION_ID, is_initialize_request, method_for_message, + method_requires_session_header, session_id_from_message, +}; + +#[derive(Debug, Error)] +pub enum HttpClientError { + #[error("invalid URL: {0}")] + InvalidUrl(#[from] url::ParseError), + #[error("failed to build HTTP client: {0}")] + Reqwest(#[from] reqwest::Error), +} + +pub struct HttpClient { + endpoint: url::Url, + http: reqwest::Client, +} + +impl std::fmt::Debug for HttpClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HttpClient") + .field("endpoint", &self.endpoint.as_str()) + .finish_non_exhaustive() + } +} + +impl HttpClient { + /// Create a client from a base URL and target the standard ACP endpoint. + /// + /// If the URL path is empty, `/acp` is used. Otherwise `/acp` is appended + /// unless the path already ends with `/acp`. + pub fn new(base_url: impl AsRef) -> Result { + Self::with_client(base_url, reqwest::Client::new()) + } + + /// Create a client that targets the exact endpoint URL. + /// + /// Use this when connecting to a server configured with a custom + /// `ServerOptions::path`. + pub fn with_endpoint(endpoint: impl AsRef) -> Result { + Self::with_endpoint_and_client(endpoint, reqwest::Client::new()) + } + + /// Create a client with a custom HTTP client and the standard ACP endpoint. + /// + /// If the URL path is empty, `/acp` is used. Otherwise `/acp` is appended + /// unless the path already ends with `/acp`. + pub fn with_client( + base_url: impl AsRef, + http: reqwest::Client, + ) -> Result { + let mut endpoint = url::Url::parse(base_url.as_ref())?; + let path = endpoint.path().trim_end_matches('/').to_string(); + let path = if path.is_empty() { + "/acp".to_string() + } else if path.ends_with("/acp") { + path + } else { + format!("{path}/acp") + }; + endpoint.set_path(&path); + Ok(Self { endpoint, http }) + } + + /// Create a client with a custom HTTP client and exact endpoint URL. + /// + /// Use this when connecting to a server configured with a custom + /// `ServerOptions::path`. + pub fn with_endpoint_and_client( + endpoint: impl AsRef, + http: reqwest::Client, + ) -> Result { + let endpoint = url::Url::parse(endpoint.as_ref())?; + Ok(Self { endpoint, http }) + } + + fn is_websocket(&self) -> bool { + matches!(self.endpoint.scheme(), "ws" | "wss") + } +} + +impl ConnectTo for HttpClient { + async fn connect_to(self, client: impl ConnectTo) -> Result<(), AcpError> { + let (channel, transport) = ConnectTo::::into_channel_and_future(self); + match futures::future::select( + std::pin::pin!(client.connect_to(channel)), + std::pin::pin!(transport), + ) + .await + { + futures::future::Either::Left((result, _)) + | futures::future::Either::Right((result, _)) => result, + } + } + + fn into_channel_and_future(self) -> (Channel, BoxFuture<'static, Result<(), AcpError>>) { + let (caller, transport) = Channel::duplex(); + (caller, Box::pin(run(self, transport))) + } +} + +async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { + if client.is_websocket() { + return run_ws(client, channel).await; + } + let HttpClient { endpoint, http } = client; + let Channel { + rx: mut outgoing, + tx: incoming, + } = channel; + let (sse_event_tx, mut sse_event_rx) = mpsc::unbounded::(); + let connection = HttpConnection::new(endpoint, http); + let mut state = ClientState { + connection: connection.clone(), + open_session_streams: HashSet::new(), + pending_requests: HashMap::new(), + incoming, + }; + let mut lifecycle = HttpTransportLifecycle::new(connection); + let mut ordered_posts = PostQueue::default(); + let mut response_posts = PostQueue::default(); + + let result = loop { + let event = { + let outgoing_next = outgoing.next().fuse(); + let sse_event_next = sse_event_rx.next().fuse(); + let sse_failure_next = lifecycle.next_sse_failure().fuse(); + let ordered_post_next = ordered_posts.next_completion().fuse(); + let response_post_next = response_posts.next_completion().fuse(); + pin_mut!( + outgoing_next, + sse_event_next, + sse_failure_next, + ordered_post_next, + response_post_next + ); + + futures::select! { + msg = outgoing_next => HttpLoopEvent::Outgoing(msg), + event = sse_event_next => HttpLoopEvent::SseEvent(event), + failure = sse_failure_next => HttpLoopEvent::SseFailure(failure), + post = ordered_post_next => HttpLoopEvent::Post(post), + post = response_post_next => HttpLoopEvent::Post(post), + } + }; + + let msg = match event { + HttpLoopEvent::Outgoing(msg) => match msg { + Some(Ok(msg)) => msg, + Some(Err(e)) => { + error!("upstream channel produced error: {e}"); + break Err(e); + } + None => break Ok(()), + }, + HttpLoopEvent::SseEvent(event) => { + let Some(event) = event else { + continue; + }; + let open_session_id = state.session_to_open_for_response(&event.message); + state.deliver(event.message); + if let Some(session_id) = open_session_id { + lifecycle.start_sse(Some(session_id), sse_event_tx.clone()); + } + continue; + } + HttpLoopEvent::SseFailure(failure) => { + let scope = failure.session_id.as_deref().unwrap_or("connection"); + error!(session_id = ?failure.session_id, error = %failure.error, "SSE stream ended"); + break Err(AcpError::internal_error() + .data(format!("{scope} SSE stream ended: {}", failure.error))); + } + HttpLoopEvent::Post(completed) => { + let CompletedPost { + pending_request, + result, + } = completed; + if let Err(e) = result { + state.remove_pending_request(pending_request.as_ref()); + error!("POST failed: {e}"); + break Err(AcpError::internal_error().data(format!("POST: {e}"))); + } + continue; + } + }; + + if state.connection.connection_id().is_none() { + if !is_initialize_request(&msg) { + break Err(AcpError::invalid_request() + .data("ACP HTTP transport: first message must be `initialize`")); + } + match state.initialize(msg).await { + Ok(InitializeOutcome::Connected) => { + lifecycle.start_sse(None, sse_event_tx.clone()); + } + Ok(InitializeOutcome::Rejected) => {} + Err(e) => { + error!("initialize failed: {e}"); + break Err(AcpError::internal_error().data(format!("initialize: {e}"))); + } + } + continue; + } + + if let Some(session_id) = session_id_from_message(&msg) + && state.open_session_streams.insert(session_id.clone()) + { + lifecycle.start_sse(Some(session_id), sse_event_tx.clone()); + } + + let is_response = matches!(msg, RawJsonRpcMessage::Response(_)); + match state.prepare_post(msg) { + // Responses answer SSE-delivered callbacks and must not be blocked + // behind a POST that may be waiting for that callback response. + Ok(post) if is_response => response_posts.push(post), + Ok(post) => ordered_posts.push(post), + Err(e) => { + error!("POST failed: {e}"); + break Err(AcpError::internal_error().data(format!("POST: {e}"))); + } + } + }; + + lifecycle.close().await; + result +} + +enum HttpLoopEvent { + Outgoing(Option>), + SseEvent(Option), + SseFailure(SseFailure), + Post(CompletedPost), +} + +#[derive(Debug)] +struct SseFailure { + session_id: Option, + error: String, +} + +#[derive(Debug)] +struct SseMessage { + message: RawJsonRpcMessage, +} + +#[derive(Clone, Debug)] +struct HttpConnection { + endpoint: url::Url, + http: reqwest::Client, + connection_id: Arc>>, +} + +impl HttpConnection { + fn new(endpoint: url::Url, http: reqwest::Client) -> Self { + Self { + endpoint, + http, + connection_id: Arc::new(StdMutex::new(None)), + } + } + + fn post(&self) -> reqwest::RequestBuilder { + self.http.post(self.endpoint.clone()) + } + + fn get(&self) -> reqwest::RequestBuilder { + self.http.get(self.endpoint.clone()) + } + + fn set_connection_id(&self, connection_id: String) { + *self.connection_id.lock().expect("mutex poisoned") = Some(connection_id); + } + + fn connection_id(&self) -> Option { + self.connection_id.lock().expect("mutex poisoned").clone() + } + + fn take_connection_id(&self) -> Option { + self.connection_id.lock().expect("mutex poisoned").take() + } + + fn clear_connection_id(&self, expected: &str) { + let mut connection_id = self.connection_id.lock().expect("mutex poisoned"); + if connection_id.as_deref() == Some(expected) { + *connection_id = None; + } + } + + async fn close(&self) { + let Some(connection_id) = self.connection_id() else { + return; + }; + Self::send_close( + self.http.clone(), + self.endpoint.clone(), + connection_id.clone(), + ) + .await; + self.clear_connection_id(&connection_id); + } + + fn spawn_close(&self) { + let Some(connection_id) = self.take_connection_id() else { + return; + }; + let http = self.http.clone(); + let endpoint = self.endpoint.clone(); + match tokio::runtime::Handle::try_current() { + Ok(handle) => { + drop(handle.spawn(Self::send_close(http, endpoint, connection_id))); + } + Err(e) => { + debug!("failed to spawn HTTP DELETE: {e}"); + } + } + } + + async fn send_close(http: reqwest::Client, endpoint: url::Url, connection_id: String) { + if let Err(e) = http + .delete(endpoint) + .header(HEADER_CONNECTION_ID, connection_id) + .send() + .await + { + debug!("DELETE failed (ignored): {e}"); + } + } +} + +#[derive(Debug)] +struct HttpTransportLifecycle { + connection: HttpConnection, + sse_tasks: SseTasks, +} + +impl HttpTransportLifecycle { + fn new(connection: HttpConnection) -> Self { + Self { + connection, + sse_tasks: SseTasks::default(), + } + } + + fn start_sse(&mut self, session_id: Option, event_tx: UnboundedSender) { + self.sse_tasks + .push(run_sse(self.connection.clone(), session_id, event_tx)); + } + + async fn next_sse_failure(&mut self) -> SseFailure { + self.sse_tasks.next_failure().await + } + + async fn close(&mut self) { + self.connection.close().await; + self.sse_tasks.abort_all(); + } +} + +impl Drop for HttpTransportLifecycle { + fn drop(&mut self) { + self.sse_tasks.abort_all(); + self.connection.spawn_close(); + } +} + +fn run_sse( + connection: HttpConnection, + session_id: Option, + event_tx: UnboundedSender, +) -> BoxFuture<'static, SseFailure> { + Box::pin(async move { + let label = session_id.clone(); + let error = match read_sse(connection, session_id, event_tx).await { + Ok(()) => "SSE stream closed".to_string(), + Err(e) => e, + }; + warn!(session_id = ?label, "SSE stream ended: {error}"); + SseFailure { + session_id: label, + error, + } + }) +} + +#[derive(Debug, Default)] +struct SseTasks { + handles: FuturesUnordered>, +} + +impl SseTasks { + fn push(&mut self, task: BoxFuture<'static, SseFailure>) { + self.handles.push(task); + } + + async fn next_failure(&mut self) -> SseFailure { + loop { + if let Some(failure) = self.handles.next().await { + return failure; + } + futures::future::pending::<()>().await; + } + } + + fn abort_all(&mut self) { + self.handles = FuturesUnordered::new(); + } +} + +struct ClientState { + connection: HttpConnection, + open_session_streams: HashSet, + pending_requests: HashMap, + incoming: futures::channel::mpsc::UnboundedSender>, +} + +struct PendingPost { + pending_request: Option<(RequestId, String)>, + response: BoxFuture<'static, Result<(), String>>, +} + +impl PendingPost { + fn into_completion(self) -> BoxFuture<'static, CompletedPost> { + let Self { + pending_request, + response, + } = self; + async move { + CompletedPost { + pending_request, + result: response.await, + } + } + .boxed() + } +} + +#[derive(Debug)] +struct CompletedPost { + pending_request: Option<(RequestId, String)>, + result: Result<(), String>, +} + +#[derive(Default)] +struct PostQueue { + queued: VecDeque, + in_flight: Option>, +} + +impl PostQueue { + fn push(&mut self, post: PendingPost) { + self.queued.push_back(post); + self.start_next(); + } + + async fn next_completion(&mut self) -> CompletedPost { + loop { + self.start_next(); + if let Some(in_flight) = self.in_flight.as_mut() { + let completed = in_flight.await; + self.in_flight = None; + return completed; + } + futures::future::pending::<()>().await; + } + } + + fn start_next(&mut self) { + if self.in_flight.is_none() + && let Some(post) = self.queued.pop_front() + { + self.in_flight = Some(post.into_completion()); + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum InitializeOutcome { + Connected, + Rejected, +} + +impl ClientState { + async fn initialize(&self, msg: RawJsonRpcMessage) -> Result { + let response = self + .connection + .post() + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(&msg) + .send() + .await + .map_err(|e| e.to_string())?; + + let connection_id = response + .headers() + .get(HEADER_CONNECTION_ID) + .and_then(|v| v.to_str().ok()) + .map(String::from); + if let Some(connection_id) = &connection_id { + self.connection.set_connection_id(connection_id.clone()); + } + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("HTTP {status}: {body}")); + } + + let message = response + .json::() + .await + .map_err(|e| e.to_string())?; + + if matches!( + message, + RawJsonRpcMessage::Response(RpcResponse::Error { .. }) + ) { + self.deliver(message); + self.connection.close().await; + return Ok(InitializeOutcome::Rejected); + } + + connection_id + .ok_or_else(|| format!("server did not return {HEADER_CONNECTION_ID} header"))?; + self.deliver(message); + Ok(InitializeOutcome::Connected) + } + + fn prepare_post(&mut self, msg: RawJsonRpcMessage) -> Result { + let session_id = match method_for_message(&msg) { + Some(method) => { + let session_id = session_id_from_message(&msg); + if method_requires_session_header(method) && session_id.is_none() { + return Err(format!("method `{method}` requires sessionId in params")); + } + session_id + } + None => None, + }; + let connection_id = self + .connection + .connection_id() + .ok_or_else(|| "POST attempted before initialize".to_string())?; + let mut request = self + .connection + .post() + .header("Accept", "application/json") + .header(HEADER_CONNECTION_ID, connection_id) + .json(&msg); + if let Some(session_id) = session_id { + request = request.header(HEADER_SESSION_ID, session_id); + } + + let pending_request = pending_request_for_message(&msg); + if let Some((id, method)) = &pending_request { + self.pending_requests.insert(id.clone(), method.clone()); + } + + let response = async move { + let response = request.send().await.map_err(|e| e.to_string())?; + if response.status().as_u16() != 202 && !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("HTTP {status}: {body}")); + } + Ok(()) + }; + Ok(PendingPost { + pending_request, + response: response.boxed(), + }) + } + + fn remove_pending_request(&mut self, pending_request: Option<&(RequestId, String)>) { + if let Some((id, _)) = pending_request { + self.pending_requests.remove(id); + } + } + + fn session_to_open_for_response(&mut self, msg: &RawJsonRpcMessage) -> Option { + let RawJsonRpcMessage::Response(response) = msg else { + return None; + }; + let id = msg.response_id().and_then(pending_request_key)?; + let method = self.pending_requests.remove(&id); + + if !method.as_deref().is_some_and(is_session_opening_method) { + return None; + } + let RpcResponse::Result { result, .. } = response else { + return None; + }; + let session_id = result + .get("sessionId") + .and_then(|v| v.as_str()) + .map(String::from)?; + + if self.open_session_streams.insert(session_id.clone()) { + Some(session_id) + } else { + None + } + } + + fn deliver(&self, msg: RawJsonRpcMessage) { + if self.incoming.unbounded_send(Ok(msg)).is_err() { + debug!("upstream channel closed; dropping inbound message"); + } + } +} + +fn is_session_opening_method(method: &str) -> bool { + matches!(method, "session/new" | "session/fork") +} + +async fn read_sse( + connection: HttpConnection, + session_id: Option, + event_tx: UnboundedSender, +) -> Result<(), String> { + let connection_id = connection + .connection_id() + .ok_or_else(|| "SSE attempted before initialize".to_string())?; + let mut request = connection + .get() + .header("Accept", "text/event-stream") + .header(HEADER_CONNECTION_ID, connection_id); + if let Some(session_id) = &session_id { + request = request.header(HEADER_SESSION_ID, session_id); + } + + let response = request.send().await.map_err(|e| e.to_string())?; + if !response.status().is_success() { + return Err(format!("HTTP {}", response.status())); + } + trace!(session_id = ?session_id, "SSE stream open"); + + let mut events = eventsource_stream::EventStream::new(response.bytes_stream()); + while let Some(event) = events.next().await { + let event = event.map_err(|e| e.to_string())?; + let payload = event.data; + if payload.is_empty() { + continue; + } + let msg = serde_json::from_str::(&payload) + .map_err(|e| format!("malformed JSON-RPC payload: {e}"))?; + + if event_tx + .unbounded_send(SseMessage { message: msg }) + .is_err() + { + return Err("upstream channel closed".to_string()); + } + } + Ok(()) +} + +fn pending_request_for_message(msg: &RawJsonRpcMessage) -> Option<(RequestId, String)> { + let RawJsonRpcMessage::Request(request) = msg else { + return None; + }; + pending_request_key(&request.id).map(|id| (id, request.method.to_string())) +} + +fn pending_request_key(id: &RequestId) -> Option { + match id { + RequestId::Null => None, + RequestId::Number(_) | RequestId::Str(_) => Some(id.clone()), + } +} + +async fn run_ws(client: HttpClient, channel: Channel) -> Result<(), AcpError> { + let HttpClient { endpoint, .. } = client; + let Channel { + rx: mut outgoing, + tx: incoming, + } = channel; + + let (ws_stream, response) = async_tungstenite::tokio::connect_async(endpoint.as_str()) + .await + .map_err(|e| AcpError::internal_error().data(format!("WebSocket connect failed: {e}")))?; + trace!( + status = %response.status(), + "WebSocket connection established" + ); + let (mut ws_tx, mut ws_rx) = ws_stream.split(); + + loop { + let outgoing_next = outgoing.next().fuse(); + let frame_next = ws_rx.next().fuse(); + pin_mut!(outgoing_next, frame_next); + + futures::select! { + msg = outgoing_next => match msg { + Some(Ok(msg)) => { + let text = match serde_json::to_string(&msg) { + Ok(t) => t, + Err(e) => { + error!("failed to serialize outbound message: {e}"); + return Err(AcpError::internal_error() + .data(format!("serialize: {e}"))); + } + }; + if let Err(e) = ws_tx.send(WsMessage::Text(text.into())).await { + error!("WebSocket send failed: {e}"); + return Err(AcpError::internal_error() + .data(format!("ws send: {e}"))); + } + } + Some(Err(e)) => { + error!("upstream channel produced error: {e}"); + return Err(e); + } + None => break, + }, + frame = frame_next => match frame { + Some(Ok(WsMessage::Text(text))) => { + match serde_json::from_str::(text.as_str()) { + Ok(parsed) => { + if incoming.unbounded_send(Ok(parsed)).is_err() { + debug!("upstream channel closed; stopping WS reader"); + break; + } + } + Err(e) => { + let message = format!("malformed JSON-RPC payload: {e}"); + warn!("WS: {message}"); + if incoming + .unbounded_send(Err(AcpError::parse_error().data(message))) + .is_err() + { + debug!("upstream channel closed; stopping WS reader"); + break; + } + } + } + } + Some(Ok(WsMessage::Binary(_))) => { + warn!("ignoring binary WebSocket frame (ACP uses text)"); + } + Some(Ok( + WsMessage::Ping(_) | WsMessage::Pong(_) | WsMessage::Frame(_), + )) => {} + Some(Ok(WsMessage::Close(frame))) => { + debug!("server closed WebSocket: {frame:?}"); + return Err(AcpError::internal_error() + .data(format!("WebSocket closed by peer: {frame:?}"))); + } + Some(Err(e)) => { + error!("WebSocket receive error: {e}"); + return Err(AcpError::internal_error() + .data(format!("ws recv: {e}"))); + } + None => { + return Err(AcpError::internal_error().data("WebSocket stream ended")); + } + }, + } + } + + drop(ws_tx.send(WsMessage::Close(None)).await); + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{ + convert::Infallible, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, + }; + + use agent_client_protocol::schema::RequestId; + use axum::{ + Json, Router, + extract::{WebSocketUpgrade, ws::Message as AxumWsMessage}, + http::{HeaderMap, HeaderValue, StatusCode}, + response::{IntoResponse, Sse, sse::Event}, + routing::{get, post}, + }; + use serde_json::json; + use tokio::{ + net::TcpListener, + sync::Notify, + time::{sleep, timeout}, + }; + + use super::*; + + #[test] + fn new_targets_standard_acp_endpoint() { + assert_eq!( + HttpClient::new("http://example.com") + .unwrap() + .endpoint + .as_str(), + "http://example.com/acp" + ); + assert_eq!( + HttpClient::new("http://example.com/proxy") + .unwrap() + .endpoint + .as_str(), + "http://example.com/proxy/acp" + ); + assert_eq!( + HttpClient::new("http://example.com/proxy/acp") + .unwrap() + .endpoint + .as_str(), + "http://example.com/proxy/acp" + ); + } + + #[test] + fn with_endpoint_preserves_explicit_endpoint_path() { + assert_eq!( + HttpClient::with_endpoint("http://example.com/agent") + .unwrap() + .endpoint + .as_str(), + "http://example.com/agent" + ); + assert_eq!( + HttpClient::with_endpoint_and_client( + "ws://example.com/custom/acp?token=abc", + reqwest::Client::new(), + ) + .unwrap() + .endpoint + .as_str(), + "ws://example.com/custom/acp?token=abc" + ); + } + + #[tokio::test] + async fn post_sends_cancel_request_without_session_header() { + let (capture_tx, mut capture_rx) = tokio::sync::mpsc::unbounded_channel(); + let post_count = Arc::new(AtomicUsize::new(0)); + let app = Router::new().route( + "/acp", + post({ + let capture_tx = capture_tx.clone(); + let post_count = post_count.clone(); + move |headers: HeaderMap, Json(message): Json| { + let capture_tx = capture_tx.clone(); + let post_count = post_count.clone(); + async move { + if post_count.fetch_add(1, Ordering::SeqCst) == 0 { + return initialize_response().await.into_response(); + } + + capture_tx + .send((headers.get(HEADER_SESSION_ID).cloned(), message)) + .unwrap(); + StatusCode::ACCEPTED.into_response() + } + } + }) + .get(pending_sse) + .delete(|| async { StatusCode::ACCEPTED }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("http://{addr}")).unwrap(); + let (mut caller, transport) = Channel::duplex(); + let transport = tokio::spawn(run(client, transport)); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "initialize".to_string(), + json!({}), + RequestId::Number(1), + ) + .unwrap())) + .unwrap(); + let init_response = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!(init_response, RawJsonRpcMessage::Response(_))); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::notification( + "$/cancel_request".to_string(), + json!({ + "requestId": 2, + "sessionId": "session-1" + }), + ) + .unwrap())) + .unwrap(); + + let (session_header, message) = timeout(Duration::from_secs(1), capture_rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(session_header.is_none()); + assert!(matches!( + message, + RawJsonRpcMessage::Notification(notification) + if notification.method.as_ref() == "$/cancel_request" + )); + + drop(caller); + timeout(Duration::from_secs(1), transport) + .await + .unwrap() + .unwrap() + .unwrap(); + + server.abort(); + } + + #[tokio::test] + async fn custom_response_with_session_id_does_not_open_session_sse() { + let (get_tx, mut get_rx) = tokio::sync::mpsc::unbounded_channel(); + let response_ready = Arc::new(tokio::sync::Notify::new()); + let post_count = Arc::new(AtomicUsize::new(0)); + let app = Router::new().route( + "/acp", + post({ + let post_count = post_count.clone(); + let response_ready = response_ready.clone(); + move |Json(_message): Json| { + let post_count = post_count.clone(); + let response_ready = response_ready.clone(); + async move { + if post_count.fetch_add(1, Ordering::SeqCst) == 0 { + return initialize_response().await.into_response(); + } + + response_ready.notify_waiters(); + StatusCode::ACCEPTED.into_response() + } + } + }) + .get({ + let get_tx = get_tx.clone(); + let response_ready = response_ready.clone(); + move |headers: HeaderMap| { + let get_tx = get_tx.clone(); + let response_ready = response_ready.clone(); + async move { + let session_header = headers + .get(HEADER_SESSION_ID) + .and_then(|value| value.to_str().ok()) + .map(String::from); + get_tx.send(session_header).unwrap(); + + let stream = async_stream::stream! { + response_ready.notified().await; + yield Ok::<_, Infallible>(sse_event( + RawJsonRpcMessage::response( + RequestId::Number(2), + Ok(json!({ "sessionId": "session-1" })), + ), + )); + futures::future::pending::<()>().await; + }; + Sse::new(stream) + } + } + }) + .delete(|| async { StatusCode::ACCEPTED }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("http://{addr}")).unwrap(); + let (mut caller, transport) = Channel::duplex(); + let transport = tokio::spawn(run(client, transport)); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "initialize".to_string(), + json!({}), + RequestId::Number(1), + ) + .unwrap())) + .unwrap(); + let init_response = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!(init_response, RawJsonRpcMessage::Response(_))); + + let connection_sse_header = timeout(Duration::from_secs(1), get_rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(connection_sse_header.is_none()); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "custom/sessionish".to_string(), + json!({}), + RequestId::Number(2), + ) + .unwrap())) + .unwrap(); + let response = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!( + response, + RawJsonRpcMessage::Response(RpcResponse::Result { + id: RequestId::Number(2), + .. + }) + )); + + assert!( + timeout(Duration::from_millis(100), get_rx.recv()) + .await + .is_err(), + "custom response must not open a session SSE stream" + ); + + drop(caller); + timeout(Duration::from_secs(1), transport) + .await + .unwrap() + .unwrap() + .unwrap(); + + server.abort(); + } + + #[tokio::test] + async fn fork_response_with_session_id_opens_session_sse() { + let (get_tx, mut get_rx) = tokio::sync::mpsc::unbounded_channel(); + let response_ready = Arc::new(tokio::sync::Notify::new()); + let post_count = Arc::new(AtomicUsize::new(0)); + let app = Router::new().route( + "/acp", + post({ + let post_count = post_count.clone(); + let response_ready = response_ready.clone(); + move |Json(_message): Json| { + let post_count = post_count.clone(); + let response_ready = response_ready.clone(); + async move { + if post_count.fetch_add(1, Ordering::SeqCst) == 0 { + return initialize_response().await.into_response(); + } + + response_ready.notify_waiters(); + StatusCode::ACCEPTED.into_response() + } + } + }) + .get({ + let get_tx = get_tx.clone(); + let response_ready = response_ready.clone(); + move |headers: HeaderMap| { + let get_tx = get_tx.clone(); + let response_ready = response_ready.clone(); + async move { + let session_header = headers + .get(HEADER_SESSION_ID) + .and_then(|value| value.to_str().ok()) + .map(String::from); + let is_connection_stream = session_header.is_none(); + get_tx.send(session_header).unwrap(); + + let stream = async_stream::stream! { + if is_connection_stream { + response_ready.notified().await; + yield Ok::<_, Infallible>(sse_event( + RawJsonRpcMessage::response( + RequestId::Number(2), + Ok(json!({ "sessionId": "forked-session" })), + ), + )); + } + futures::future::pending::<()>().await; + }; + Sse::new(stream) + } + } + }) + .delete(|| async { StatusCode::ACCEPTED }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("http://{addr}")).unwrap(); + let (mut caller, transport) = Channel::duplex(); + let transport = tokio::spawn(run(client, transport)); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "initialize".to_string(), + json!({}), + RequestId::Number(1), + ) + .unwrap())) + .unwrap(); + let init_response = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!(init_response, RawJsonRpcMessage::Response(_))); + + let connection_sse_header = timeout(Duration::from_secs(1), get_rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(connection_sse_header.is_none()); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "session/fork".to_string(), + json!({ "sessionId": "source-session" }), + RequestId::Number(2), + ) + .unwrap())) + .unwrap(); + let source_sse_header = timeout(Duration::from_secs(1), get_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(source_sse_header.as_deref(), Some("source-session")); + + let response = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!( + response, + RawJsonRpcMessage::Response(RpcResponse::Result { + id: RequestId::Number(2), + .. + }) + )); + + let fork_sse_header = timeout(Duration::from_secs(1), get_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(fork_sse_header.as_deref(), Some("forked-session")); + + drop(caller); + timeout(Duration::from_secs(1), transport) + .await + .unwrap() + .unwrap() + .unwrap(); + + server.abort(); + } + + #[tokio::test] + async fn outbound_posts_are_sent_in_order() { + let first_started = Arc::new(Notify::new()); + let release_first = Arc::new(Notify::new()); + let second_seen = Arc::new(Notify::new()); + let app = Router::new().route( + "/acp", + post({ + let first_started = first_started.clone(); + let release_first = release_first.clone(); + let second_seen = second_seen.clone(); + move |Json(message): Json| { + let first_started = first_started.clone(); + let release_first = release_first.clone(); + let second_seen = second_seen.clone(); + async move { + if is_initialize_request(&message) { + return initialize_response().await.into_response(); + } + + match method_for_message(&message) { + Some("custom/first") => { + first_started.notify_one(); + release_first.notified().await; + } + Some("custom/second") => { + second_seen.notify_one(); + } + _ => {} + } + StatusCode::ACCEPTED.into_response() + } + } + }) + .get(pending_sse) + .delete(|| async { StatusCode::ACCEPTED }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("http://{addr}")).unwrap(); + let (mut caller, transport) = Channel::duplex(); + let transport = tokio::spawn(run(client, transport)); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "initialize".to_string(), + json!({}), + RequestId::Number(1), + ) + .unwrap())) + .unwrap(); + let init_response = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!(init_response, RawJsonRpcMessage::Response(_))); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::notification( + "custom/first".to_string(), + json!({}), + ) + .unwrap())) + .unwrap(); + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::notification( + "custom/second".to_string(), + json!({}), + ) + .unwrap())) + .unwrap(); + + timeout(Duration::from_secs(1), first_started.notified()) + .await + .unwrap(); + assert!( + timeout(Duration::from_millis(100), second_seen.notified()) + .await + .is_err(), + "second POST must not be sent while the first POST is pending" + ); + + release_first.notify_one(); + timeout(Duration::from_secs(1), second_seen.notified()) + .await + .unwrap(); + + drop(caller); + timeout(Duration::from_secs(1), transport) + .await + .unwrap() + .unwrap() + .unwrap(); + + server.abort(); + } + + #[tokio::test] + async fn sse_continues_while_post_is_pending() { + let post_started = Arc::new(Notify::new()); + let callback_response_seen = Arc::new(Notify::new()); + let sse_started = Arc::new(Notify::new()); + let (callback_tx, mut callback_rx) = tokio::sync::mpsc::unbounded_channel(); + let app = Router::new().route( + "/acp", + post({ + let post_started = post_started.clone(); + let callback_response_seen = callback_response_seen.clone(); + let callback_tx = callback_tx.clone(); + move |Json(message): Json| { + let post_started = post_started.clone(); + let callback_response_seen = callback_response_seen.clone(); + let callback_tx = callback_tx.clone(); + async move { + if is_initialize_request(&message) { + return initialize_response().await.into_response(); + } + + match &message { + RawJsonRpcMessage::Request(request) + if request.method.as_ref() == "custom/slow" => + { + post_started.notify_waiters(); + callback_response_seen.notified().await; + StatusCode::ACCEPTED.into_response() + } + RawJsonRpcMessage::Response( + RpcResponse::Result { + id: RequestId::Number(99), + .. + } + | RpcResponse::Error { + id: RequestId::Number(99), + .. + }, + ) => { + callback_tx.send(message).unwrap(); + callback_response_seen.notify_waiters(); + StatusCode::ACCEPTED.into_response() + } + _ => StatusCode::ACCEPTED.into_response(), + } + } + } + }) + .get({ + let post_started = post_started.clone(); + let sse_started = sse_started.clone(); + move || { + let post_started = post_started.clone(); + let sse_started = sse_started.clone(); + async move { + let stream = async_stream::stream! { + sse_started.notify_waiters(); + post_started.notified().await; + yield Ok::<_, Infallible>(sse_event( + RawJsonRpcMessage::request( + "client/callback".to_string(), + json!({}), + RequestId::Number(99), + ) + .unwrap(), + )); + futures::future::pending::<()>().await; + }; + Sse::new(stream) + } + } + }) + .delete(|| async { StatusCode::ACCEPTED }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("http://{addr}")).unwrap(); + let (mut caller, transport) = Channel::duplex(); + let transport = tokio::spawn(run(client, transport)); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "initialize".to_string(), + json!({}), + RequestId::Number(1), + ) + .unwrap())) + .unwrap(); + let init_response = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!(init_response, RawJsonRpcMessage::Response(_))); + timeout(Duration::from_secs(1), sse_started.notified()) + .await + .unwrap(); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "custom/slow".to_string(), + json!({}), + RequestId::Number(2), + ) + .unwrap())) + .unwrap(); + + let callback = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!( + callback, + RawJsonRpcMessage::Request(request) + if request.method.as_ref() == "client/callback" + && request.id == RequestId::Number(99) + )); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::response( + RequestId::Number(99), + Ok(json!({})), + ))) + .unwrap(); + let callback_response = timeout(Duration::from_secs(1), callback_rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(matches!( + callback_response, + RawJsonRpcMessage::Response(RpcResponse::Result { + id: RequestId::Number(99), + .. + }) + )); + + drop(caller); + timeout(Duration::from_secs(1), transport) + .await + .unwrap() + .unwrap() + .unwrap(); + + server.abort(); + } + + #[tokio::test] + async fn post_error_deletes_initialized_connection() { + let delete_count = Arc::new(AtomicUsize::new(0)); + let delete_count_for_handler = delete_count.clone(); + let app = Router::new().route( + "/acp", + post(initialize_response).get(pending_sse).delete(move || { + let delete_count = delete_count_for_handler.clone(); + async move { + delete_count.fetch_add(1, Ordering::SeqCst); + StatusCode::ACCEPTED + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("http://{addr}")).unwrap(); + let (mut caller, transport) = Channel::duplex(); + let transport = tokio::spawn(run(client, transport)); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "initialize".to_string(), + json!({}), + RequestId::Number(1), + ) + .unwrap())) + .unwrap(); + let init_response = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!(init_response, RawJsonRpcMessage::Response(_))); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "session/prompt".to_string(), + json!({}), + RequestId::Number(2), + ) + .unwrap())) + .unwrap(); + let error = timeout(Duration::from_secs(1), transport) + .await + .unwrap() + .unwrap() + .unwrap_err(); + + assert!(error.to_string().contains("POST")); + assert_eq!(delete_count.load(Ordering::SeqCst), 1); + + server.abort(); + } + + #[tokio::test] + async fn connection_sse_disconnect_fails_transport() { + let delete_count = Arc::new(AtomicUsize::new(0)); + let delete_count_for_handler = delete_count.clone(); + let app = Router::new().route( + "/acp", + post(initialize_response).get(closed_sse).delete(move || { + let delete_count = delete_count_for_handler.clone(); + async move { + delete_count.fetch_add(1, Ordering::SeqCst); + StatusCode::ACCEPTED + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("http://{addr}")).unwrap(); + let (mut caller, transport) = Channel::duplex(); + let transport = tokio::spawn(run(client, transport)); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "initialize".to_string(), + json!({}), + RequestId::Number(1), + ) + .unwrap())) + .unwrap(); + let init_response = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!(init_response, RawJsonRpcMessage::Response(_))); + + let error = timeout(Duration::from_secs(1), transport) + .await + .unwrap() + .unwrap() + .unwrap_err(); + + assert!(error.to_string().contains("SSE")); + assert_eq!(delete_count.load(Ordering::SeqCst), 1); + + server.abort(); + } + + #[tokio::test] + async fn malformed_sse_json_fails_transport() { + let delete_count = Arc::new(AtomicUsize::new(0)); + let delete_count_for_handler = delete_count.clone(); + let app = Router::new().route( + "/acp", + post(initialize_response) + .get(malformed_sse) + .delete(move || { + let delete_count = delete_count_for_handler.clone(); + async move { + delete_count.fetch_add(1, Ordering::SeqCst); + StatusCode::ACCEPTED + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("http://{addr}")).unwrap(); + let (mut caller, transport) = Channel::duplex(); + let transport = tokio::spawn(run(client, transport)); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "initialize".to_string(), + json!({}), + RequestId::Number(1), + ) + .unwrap())) + .unwrap(); + let init_response = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!(init_response, RawJsonRpcMessage::Response(_))); + + let error = timeout(Duration::from_secs(1), transport) + .await + .unwrap() + .unwrap() + .unwrap_err(); + + assert!(error.to_string().contains("malformed JSON-RPC payload")); + assert_eq!(delete_count.load(Ordering::SeqCst), 1); + + server.abort(); + } + + #[tokio::test] + async fn malformed_ws_json_reports_parse_error_and_continues() { + let app = Router::new().route("/acp", get(malformed_then_valid_ws)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("ws://{addr}")).unwrap(); + let (mut caller, transport) = Channel::duplex(); + let transport = tokio::spawn(run(client, transport)); + + let error = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap_err(); + assert!(error.to_string().contains("malformed JSON-RPC payload")); + + let message = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!(message, RawJsonRpcMessage::Response(_))); + + drop(caller); + timeout(Duration::from_secs(1), transport) + .await + .unwrap() + .unwrap() + .unwrap(); + + server.abort(); + } + + #[tokio::test] + async fn peer_ws_close_fails_transport() { + let app = Router::new().route("/acp", get(close_ws)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("ws://{addr}")).unwrap(); + let (_caller, transport) = Channel::duplex(); + let transport = tokio::spawn(run(client, transport)); + + let error = timeout(Duration::from_secs(1), transport) + .await + .unwrap() + .unwrap() + .unwrap_err(); + assert!(error.to_string().contains("WebSocket closed by peer")); + + server.abort(); + } + + #[tokio::test] + async fn dropped_transport_future_deletes_initialized_connection() { + let delete_count = Arc::new(AtomicUsize::new(0)); + let delete_count_for_handler = delete_count.clone(); + let app = Router::new().route( + "/acp", + post(initialize_response).get(pending_sse).delete(move || { + let delete_count = delete_count_for_handler.clone(); + async move { + delete_count.fetch_add(1, Ordering::SeqCst); + StatusCode::ACCEPTED + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("http://{addr}")).unwrap(); + let (mut caller, transport) = Channel::duplex(); + let mut transport = Box::pin(run(client, transport)); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "initialize".to_string(), + json!({}), + RequestId::Number(1), + ) + .unwrap())) + .unwrap(); + let init_response = timeout(Duration::from_secs(1), async { + tokio::select! { + result = &mut transport => { + panic!("transport ended before initialize response: {result:?}"); + } + msg = caller.rx.next() => { + msg.unwrap().unwrap() + } + } + }) + .await + .unwrap(); + assert!(matches!(init_response, RawJsonRpcMessage::Response(_))); + + drop(transport); + wait_for_delete(&delete_count).await; + + server.abort(); + } + + #[tokio::test] + async fn dropped_transport_during_close_retries_delete() { + let delete_count = Arc::new(AtomicUsize::new(0)); + let delete_count_for_handler = delete_count.clone(); + let release_delete = Arc::new(Notify::new()); + let release_delete_for_handler = release_delete.clone(); + let app = Router::new().route( + "/acp", + post(initialize_response).get(pending_sse).delete(move || { + let delete_count = delete_count_for_handler.clone(); + let release_delete = release_delete_for_handler.clone(); + async move { + delete_count.fetch_add(1, Ordering::SeqCst); + release_delete.notified().await; + StatusCode::ACCEPTED + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("http://{addr}")).unwrap(); + let (mut caller, transport) = Channel::duplex(); + let transport = tokio::spawn(run(client, transport)); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "initialize".to_string(), + json!({}), + RequestId::Number(1), + ) + .unwrap())) + .unwrap(); + let init_response = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!(init_response, RawJsonRpcMessage::Response(_))); + + drop(caller); + wait_for_delete_count(&delete_count, 1).await; + transport.abort(); + wait_for_delete_count(&delete_count, 2).await; + release_delete.notify_waiters(); + drop(transport.await); + + server.abort(); + } + + #[tokio::test] + async fn initialize_error_without_connection_id_is_delivered_without_sse() { + let get_count = Arc::new(AtomicUsize::new(0)); + let get_count_for_handler = get_count.clone(); + let app = Router::new().route( + "/acp", + post(initialize_error_response).get(move || { + let get_count = get_count_for_handler.clone(); + async move { + get_count.fetch_add(1, Ordering::SeqCst); + pending_sse().await + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("http://{addr}")).unwrap(); + let (mut caller, transport) = Channel::duplex(); + let transport = tokio::spawn(run(client, transport)); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "initialize".to_string(), + json!({}), + RequestId::Number(1), + ) + .unwrap())) + .unwrap(); + let init_response = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + + assert!(matches!( + init_response, + RawJsonRpcMessage::Response(RpcResponse::Error { + id: RequestId::Number(1), + .. + }) + )); + assert_eq!(get_count.load(Ordering::SeqCst), 0); + + drop(caller); + timeout(Duration::from_secs(1), transport) + .await + .unwrap() + .unwrap() + .unwrap(); + + server.abort(); + } + + #[tokio::test] + async fn malformed_initialize_body_with_connection_id_is_deleted() { + let delete_count = Arc::new(AtomicUsize::new(0)); + let delete_count_for_handler = delete_count.clone(); + let app = Router::new().route( + "/acp", + post(malformed_initialize_response).delete(move || { + let delete_count = delete_count_for_handler.clone(); + async move { + delete_count.fetch_add(1, Ordering::SeqCst); + StatusCode::ACCEPTED + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("http://{addr}")).unwrap(); + let (caller, transport) = Channel::duplex(); + let transport = tokio::spawn(run(client, transport)); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "initialize".to_string(), + json!({}), + RequestId::Number(1), + ) + .unwrap())) + .unwrap(); + let error = timeout(Duration::from_secs(1), transport) + .await + .unwrap() + .unwrap() + .unwrap_err(); + + assert!(error.to_string().contains("initialize")); + wait_for_delete(&delete_count).await; + + server.abort(); + } + + async fn wait_for_delete(delete_count: &AtomicUsize) { + wait_for_delete_count(delete_count, 1).await; + assert_eq!(delete_count.load(Ordering::SeqCst), 1); + } + + async fn wait_for_delete_count(delete_count: &AtomicUsize, expected: usize) { + timeout(Duration::from_secs(1), async { + loop { + if delete_count.load(Ordering::SeqCst) >= expected { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + } + + async fn initialize_response() -> impl IntoResponse { + let mut headers = HeaderMap::new(); + headers.insert(HEADER_CONNECTION_ID, HeaderValue::from_static("conn-1")); + ( + StatusCode::OK, + headers, + Json(RawJsonRpcMessage::response( + RequestId::Number(1), + Ok(json!({})), + )), + ) + } + + async fn initialize_error_response() -> Json { + Json(RawJsonRpcMessage::response( + RequestId::Number(1), + Err(AcpError::invalid_request().data("initialize rejected")), + )) + } + + async fn malformed_initialize_response() -> impl IntoResponse { + let mut headers = HeaderMap::new(); + headers.insert(HEADER_CONNECTION_ID, HeaderValue::from_static("conn-1")); + (StatusCode::OK, headers, "{not json") + } + + async fn pending_sse() -> Sse>> { + Sse::new(futures::stream::pending()) + } + + fn sse_event(message: RawJsonRpcMessage) -> Event { + Event::default().data(serde_json::to_string(&message).unwrap()) + } + + async fn malformed_sse() -> Sse>> { + let invalid = futures::stream::once(async { + Ok::<_, Infallible>(Event::default().data("{not json")) + }); + Sse::new(invalid.chain(futures::stream::pending())) + } + + async fn malformed_then_valid_ws(ws: WebSocketUpgrade) -> impl IntoResponse { + ws.on_upgrade(|mut socket| async move { + drop(socket.send(AxumWsMessage::Text("{not json".into())).await); + let valid = serde_json::to_string(&RawJsonRpcMessage::response( + RequestId::Number(1), + Ok(json!({})), + )) + .unwrap(); + drop(socket.send(AxumWsMessage::Text(valid.into())).await); + futures::future::pending::<()>().await; + }) + } + + async fn close_ws(ws: WebSocketUpgrade) -> impl IntoResponse { + ws.on_upgrade(|mut socket| async move { + drop(socket.send(AxumWsMessage::Close(None)).await); + }) + } + + async fn closed_sse() -> StatusCode { + StatusCode::OK + } +} diff --git a/src/agent-client-protocol-http/src/connection.rs b/src/agent-client-protocol-http/src/connection.rs new file mode 100644 index 00000000..2c2b91e2 --- /dev/null +++ b/src/agent-client-protocol-http/src/connection.rs @@ -0,0 +1,807 @@ +use std::{ + collections::{HashMap, VecDeque}, + sync::{Arc, Weak}, +}; + +use agent_client_protocol::{Channel, RawJsonRpcMessage, schema::RequestId}; +use futures::{SinkExt, StreamExt}; +use tokio::sync::{Mutex, RwLock, mpsc, watch}; +use tracing::{debug, error, trace}; + +use crate::protocol::session_id_from_message; + +#[derive(Clone, Debug)] +pub(crate) enum ResponseRoute { + Connection, + Session(String), +} + +enum OutboundTransport { + Http(HttpOutbound), + WebSocket(WebSocketOutbound), +} + +struct HttpOutbound { + connection_stream: Arc, + session_streams: RwLock>>, + pending_routes: Mutex>, +} + +struct WebSocketOutbound { + all_outbound: Arc, +} + +struct OutboundStream { + state: Mutex, +} + +struct OutboundStreamState { + replay: Option>, + subscribers: Vec>, +} + +impl OutboundStream { + fn new() -> Self { + Self { + state: Mutex::new(OutboundStreamState { + replay: Some(VecDeque::new()), + subscribers: Vec::new(), + }), + } + } + + async fn push(&self, msg: String) { + let mut state = self.state.lock().await; + if let Some(replay) = state.replay.as_mut() { + if replay.len() == OUTBOUND_STREAM_CAPACITY { + replay.pop_front(); + } + replay.push_back(msg); + } else { + state + .subscribers + .retain(|subscriber| match subscriber.try_send(msg.clone()) { + Ok(()) => true, + Err(mpsc::error::TrySendError::Full(_)) => { + debug!("outbound subscriber queue full; closing subscriber stream"); + false + } + Err(mpsc::error::TrySendError::Closed(_)) => false, + }); + } + } + + async fn subscribe(&self) -> (Vec, mpsc::Receiver) { + let mut state = self.state.lock().await; + let (tx, receiver) = mpsc::channel(OUTBOUND_STREAM_CAPACITY); + state.subscribers.push(tx); + ( + state.replay.take().map(Vec::from).unwrap_or_default(), + receiver, + ) + } +} + +pub(crate) const OUTBOUND_STREAM_CAPACITY: usize = 1024; + +pub(crate) struct Connection { + inbound_tx: mpsc::UnboundedSender>, + outbound_rx: Mutex>>, + agent_handle: Mutex>>, + router_handle: Mutex>>, + closed_tx: watch::Sender, + outbound_transport: OutboundTransport, +} + +impl Connection { + pub(crate) fn send_to_agent(&self, msg: RawJsonRpcMessage) -> Result<(), &'static str> { + self.inbound_tx + .send(Ok(msg)) + .map_err(|_| "agent channel closed") + } + + pub(crate) async fn record_pending_route(&self, id: RequestId, route: ResponseRoute) { + self.outbound_transport + .record_pending_route(id, route) + .await; + } + + pub(crate) async fn ensure_session(&self, session_id: &str) { + self.outbound_transport.ensure_session(session_id).await; + } + + pub(crate) async fn subscribe_connection_stream( + &self, + ) -> (Vec, mpsc::Receiver) { + self.outbound_transport.subscribe_connection_stream().await + } + + pub(crate) async fn subscribe_session_stream( + &self, + session_id: &str, + ) -> (Vec, mpsc::Receiver) { + self.outbound_transport + .subscribe_session_stream(session_id) + .await + } + + pub(crate) async fn subscribe_all_outbound(&self) -> (Vec, mpsc::Receiver) { + self.outbound_transport.subscribe_all_outbound().await + } + + pub(crate) fn subscribe_closed(&self) -> watch::Receiver { + self.closed_tx.subscribe() + } + + #[cfg(test)] + pub(crate) async fn push_connection_stream_for_test(&self, msg: String) { + self.outbound_transport + .push_connection_stream_for_test(msg) + .await; + } + + pub(crate) async fn start_router(self: &Arc) { + let Some(mut rx) = self.outbound_rx.lock().await.take() else { + return; + }; + + let connection = self.clone(); + *self.router_handle.lock().await = Some(tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + connection.route_outbound(msg).await; + } + })); + } + + async fn route_outbound(&self, msg: RawJsonRpcMessage) { + self.outbound_transport.route_outbound(msg).await; + } + + pub(crate) async fn recv_initial(&self) -> Option { + let mut guard = self.outbound_rx.lock().await; + let rx = guard.as_mut()?; + rx.recv().await + } + + pub(crate) async fn shutdown(&self) { + self.close_streams(); + if let Some(h) = self.agent_handle.lock().await.take() { + h.abort(); + } + if let Some(h) = self.router_handle.lock().await.take() { + h.abort(); + } + } + + fn close_streams(&self) { + self.closed_tx.send_replace(true); + } +} + +impl OutboundTransport { + fn http() -> Self { + Self::Http(HttpOutbound::new()) + } + + fn websocket() -> Self { + Self::WebSocket(WebSocketOutbound::new()) + } + + async fn record_pending_route(&self, id: RequestId, route: ResponseRoute) { + let Self::Http(http) = self else { + return; + }; + + http.record_pending_route(id, route).await; + } + + async fn ensure_session(&self, session_id: &str) { + let Self::Http(http) = self else { + return; + }; + + http.ensure_session(session_id).await; + } + + async fn subscribe_connection_stream(&self) -> (Vec, mpsc::Receiver) { + match self { + Self::Http(http) => http.connection_stream.subscribe().await, + Self::WebSocket(_) => empty_subscription(), + } + } + + async fn subscribe_session_stream( + &self, + session_id: &str, + ) -> (Vec, mpsc::Receiver) { + match self { + Self::Http(http) => http.session_stream(session_id).await.subscribe().await, + Self::WebSocket(_) => empty_subscription(), + } + } + + async fn subscribe_all_outbound(&self) -> (Vec, mpsc::Receiver) { + match self { + Self::Http(_) => empty_subscription(), + Self::WebSocket(websocket) => websocket.all_outbound.subscribe().await, + } + } + + #[cfg(test)] + async fn push_connection_stream_for_test(&self, msg: String) { + let Self::Http(http) = self else { + return; + }; + + http.connection_stream.push(msg).await; + } + + async fn route_outbound(&self, msg: RawJsonRpcMessage) { + let serialized = match serde_json::to_string(&msg) { + Ok(s) => s, + Err(e) => { + error!("failed to serialize outbound JSON-RPC message: {e}"); + return; + } + }; + + match self { + Self::Http(http) => http.route_outbound(&msg, serialized).await, + Self::WebSocket(websocket) => websocket.all_outbound.push(serialized).await, + } + } +} + +impl HttpOutbound { + fn new() -> Self { + Self { + connection_stream: Arc::new(OutboundStream::new()), + session_streams: RwLock::new(HashMap::new()), + pending_routes: Mutex::new(HashMap::new()), + } + } + + async fn record_pending_route(&self, id: RequestId, route: ResponseRoute) { + if let Some(key) = pending_route_key(&id) { + self.pending_routes.lock().await.insert(key, route); + } + } + + async fn ensure_session(&self, session_id: &str) { + self.session_stream(session_id).await; + } + + async fn session_stream(&self, session_id: &str) -> Arc { + if let Some(stream) = self.session_streams.read().await.get(session_id) { + return stream.clone(); + } + + self.session_streams + .write() + .await + .entry(session_id.to_string()) + .or_insert_with(|| Arc::new(OutboundStream::new())) + .clone() + } + + async fn route_outbound(&self, msg: &RawJsonRpcMessage, serialized: String) { + let route = match msg { + RawJsonRpcMessage::Request(_) | RawJsonRpcMessage::Notification(_) => { + session_id_from_message(msg) + .map_or(ResponseRoute::Connection, ResponseRoute::Session) + } + RawJsonRpcMessage::Response(_) => { + let route = match msg.response_id().and_then(pending_route_key) { + Some(key) => self.pending_routes.lock().await.remove(&key), + None => None, + }; + route.unwrap_or(ResponseRoute::Connection) + } + }; + + match route { + ResponseRoute::Connection => { + trace!(target = "connection", "→ connection-scoped stream"); + self.connection_stream.push(serialized).await; + } + ResponseRoute::Session(sid) => { + trace!(target = %sid, "→ session-scoped stream"); + self.session_stream(&sid).await.push(serialized).await; + } + } + } +} + +impl WebSocketOutbound { + fn new() -> Self { + Self { + all_outbound: Arc::new(OutboundStream::new()), + } + } +} + +fn empty_subscription() -> (Vec, mpsc::Receiver) { + let (_tx, rx) = mpsc::channel(1); + (Vec::new(), rx) +} + +pub(crate) struct ConnectionRegistry { + factory: Arc, + connections: Arc>>>, +} + +pub(crate) trait AgentFactory: Send + Sync + 'static { + fn spawn_agent( + &self, + ) -> ( + Channel, + futures::future::BoxFuture<'static, agent_client_protocol::Result<()>>, + ); +} + +impl AgentFactory for F +where + F: Fn() -> C + Send + Sync + 'static, + C: agent_client_protocol::ConnectTo, +{ + fn spawn_agent( + &self, + ) -> ( + Channel, + futures::future::BoxFuture<'static, agent_client_protocol::Result<()>>, + ) { + self().into_channel_and_future() + } +} + +impl ConnectionRegistry { + pub(crate) fn new(factory: Arc) -> Self { + Self { + factory, + connections: Arc::new(RwLock::new(HashMap::new())), + } + } + + pub(crate) fn next_connection_id() -> String { + uuid::Uuid::new_v4().to_string() + } + + pub(crate) async fn create_connection(&self) -> (String, Arc) { + let connection_id = Self::next_connection_id(); + let connection = self.create_connection_with_id(connection_id.clone()).await; + (connection_id, connection) + } + + pub(crate) async fn create_connection_with_id(&self, connection_id: String) -> Arc { + self.create_connection_with_transport(connection_id, OutboundTransport::http()) + .await + } + + pub(crate) async fn create_websocket_connection_with_id( + &self, + connection_id: String, + ) -> Arc { + self.create_connection_with_transport(connection_id, OutboundTransport::websocket()) + .await + } + + async fn create_connection_with_transport( + &self, + connection_id: String, + outbound_transport: OutboundTransport, + ) -> Arc { + let (channel, agent_future) = self.factory.spawn_agent(); + let (inbound_tx, mut inbound_rx) = + mpsc::unbounded_channel::>(); + let (outbound_tx, outbound_rx) = mpsc::unbounded_channel::(); + let (closed_tx, _) = watch::channel(false); + + let Channel { + rx: mut agent_rx, + tx: mut agent_tx, + } = channel; + let inbound = async move { + while let Some(msg) = inbound_rx.recv().await { + if agent_tx.send(msg).await.is_err() { + break; + } + } + drop(agent_tx.close().await); + }; + let (inbound_abort, inbound_abort_registration) = futures::future::AbortHandle::new_pair(); + let inbound = futures::future::Abortable::new(inbound, inbound_abort_registration); + let inbound_abort_for_outbound = inbound_abort.clone(); + let outbound = async move { + while let Some(msg) = agent_rx.next().await { + match msg { + Ok(m) => { + if outbound_tx.send(m).is_err() { + break; + } + } + Err(e) => { + error!("agent emitted error: {e}"); + inbound_abort_for_outbound.abort(); + break; + } + } + } + }; + let pump = async move { + let (_inbound_result, ()) = futures::join!(inbound, outbound); + }; + + let connection = Arc::new(Connection { + inbound_tx, + outbound_rx: Mutex::new(Some(outbound_rx)), + agent_handle: Mutex::new(None), + router_handle: Mutex::new(None), + closed_tx, + outbound_transport, + }); + + self.connections + .write() + .await + .insert(connection_id.clone(), connection.clone()); + + let conn_id_for_task = connection_id.clone(); + let connections = self.connections.clone(); + let connection_for_task = Arc::downgrade(&connection); + let agent_handle = tokio::spawn(async move { + let conn_id_for_agent = conn_id_for_task.clone(); + let agent = async move { + if let Err(e) = agent_future.await { + error!(connection_id = %conn_id_for_agent, "ACP agent task error: {e}"); + } + }; + futures::pin_mut!(agent); + futures::pin_mut!(pump); + match futures::future::select(agent, pump).await { + futures::future::Either::Left(((), pump)) => { + inbound_abort.abort(); + pump.await; + } + futures::future::Either::Right(((), _agent)) => {} + } + debug!(connection_id = %conn_id_for_task, "HTTP ACP connection task ended"); + connections.write().await.remove(&conn_id_for_task); + close_connection_task(connection_for_task).await; + }); + + *connection.agent_handle.lock().await = Some(agent_handle); + + connection + } + + pub(crate) async fn get(&self, connection_id: &str) -> Option> { + self.connections.read().await.get(connection_id).cloned() + } + + pub(crate) async fn remove(&self, connection_id: &str) -> Option> { + self.connections.write().await.remove(connection_id) + } + + #[cfg(test)] + pub(crate) async fn len(&self) -> usize { + self.connections.read().await.len() + } +} + +async fn close_connection_task(connection: Weak) { + let Some(connection) = connection.upgrade() else { + return; + }; + connection.close_streams(); + if let Some(h) = connection.router_handle.lock().await.take() { + h.abort(); + } +} + +fn pending_route_key(id: &RequestId) -> Option { + match id { + RequestId::Null => None, + RequestId::Number(_) | RequestId::Str(_) => Some(id.clone()), + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use futures::future::BoxFuture; + use tokio::{ + sync::Notify, + time::{Duration, sleep, timeout}, + }; + + use super::*; + + struct ExitingAgentFactory { + exit: Arc, + } + + impl AgentFactory for ExitingAgentFactory { + fn spawn_agent( + &self, + ) -> ( + Channel, + BoxFuture<'static, agent_client_protocol::Result<()>>, + ) { + let (agent, transport) = Channel::duplex(); + let exit = self.exit.clone(); + let future = Box::pin(async move { + exit.notified().await; + drop(agent); + Ok(()) + }); + + (transport, future) + } + } + + struct RespondThenExitAgentFactory; + + impl AgentFactory for RespondThenExitAgentFactory { + fn spawn_agent( + &self, + ) -> ( + Channel, + BoxFuture<'static, agent_client_protocol::Result<()>>, + ) { + let (agent, transport) = Channel::duplex(); + let future = Box::pin(async move { + agent + .tx + .unbounded_send(Ok(RawJsonRpcMessage::response( + RequestId::Number(1), + Ok(serde_json::json!({ "done": true })), + ))) + .unwrap(); + Ok(()) + }); + + (transport, future) + } + } + + struct ErrorThenWaitAgentFactory { + emit: Arc, + } + + impl AgentFactory for ErrorThenWaitAgentFactory { + fn spawn_agent( + &self, + ) -> ( + Channel, + BoxFuture<'static, agent_client_protocol::Result<()>>, + ) { + let (agent, transport) = Channel::duplex(); + let emit = self.emit.clone(); + let future = Box::pin(async move { + emit.notified().await; + agent + .tx + .unbounded_send(Err( + agent_client_protocol::Error::parse_error().data("transport parse error") + )) + .unwrap(); + std::future::pending::>().await + }); + + (transport, future) + } + } + + struct SendThenWaitAgentFactory { + message: RawJsonRpcMessage, + exit: Arc, + } + + impl AgentFactory for SendThenWaitAgentFactory { + fn spawn_agent( + &self, + ) -> ( + Channel, + BoxFuture<'static, agent_client_protocol::Result<()>>, + ) { + let (agent, transport) = Channel::duplex(); + let message = self.message.clone(); + let exit = self.exit.clone(); + let future = Box::pin(async move { + agent.tx.unbounded_send(Ok(message)).unwrap(); + exit.notified().await; + Ok(()) + }); + + (transport, future) + } + } + + #[tokio::test] + async fn agent_exit_removes_connection_and_closes_streams() { + let exit = Arc::new(Notify::new()); + let registry = + ConnectionRegistry::new(Arc::new(ExitingAgentFactory { exit: exit.clone() })); + let (connection_id, connection) = registry.create_connection().await; + + assert!(registry.get(&connection_id).await.is_some()); + + exit.notify_one(); + timeout(Duration::from_secs(1), async { + loop { + if registry.get(&connection_id).await.is_none() { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + + assert!(*connection.subscribe_closed().borrow()); + } + + #[tokio::test] + async fn agent_error_removes_connection_and_closes_streams() { + let emit = Arc::new(Notify::new()); + let registry = + ConnectionRegistry::new(Arc::new(ErrorThenWaitAgentFactory { emit: emit.clone() })); + let (connection_id, connection) = registry.create_connection().await; + let mut closed = connection.subscribe_closed(); + + assert!(registry.get(&connection_id).await.is_some()); + + connection.start_router().await; + emit.notify_one(); + + timeout(Duration::from_secs(1), async { + loop { + if *closed.borrow() { + break; + } + closed.changed().await.unwrap(); + } + }) + .await + .unwrap(); + + assert!(registry.get(&connection_id).await.is_none()); + } + + #[tokio::test] + async fn agent_exit_drains_buffered_outbound_messages() { + let registry = ConnectionRegistry::new(Arc::new(RespondThenExitAgentFactory)); + let (connection_id, connection) = registry.create_connection().await; + + let message = timeout(Duration::from_secs(1), connection.recv_initial()) + .await + .unwrap() + .expect("buffered response should be forwarded before teardown"); + + assert!(matches!( + message, + RawJsonRpcMessage::Response(agent_client_protocol::schema::Response::Result { + id: RequestId::Number(1), + .. + }) + )); + timeout(Duration::from_secs(1), async { + loop { + if registry.get(&connection_id).await.is_none() { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + assert!(*connection.subscribe_closed().borrow()); + } + + #[tokio::test] + async fn protocol_level_notification_routes_to_connection_stream() { + let exit = Arc::new(Notify::new()); + let message = RawJsonRpcMessage::notification( + "$/cancel_request".to_string(), + serde_json::json!({ + "requestId": 1, + "sessionId": "session-1" + }), + ) + .unwrap(); + let registry = ConnectionRegistry::new(Arc::new(SendThenWaitAgentFactory { + message, + exit: exit.clone(), + })); + let (_connection_id, connection) = registry.create_connection().await; + let (_connection_replay, mut connection_rx) = + connection.subscribe_connection_stream().await; + let (_session_replay, mut session_rx) = + connection.subscribe_session_stream("session-1").await; + + connection.start_router().await; + + let text = timeout(Duration::from_secs(1), connection_rx.recv()) + .await + .unwrap() + .expect("protocol-level notification should reach connection stream"); + let routed = serde_json::from_str::(&text).unwrap(); + assert!(matches!( + routed, + RawJsonRpcMessage::Notification(notification) + if notification.method.as_ref() == "$/cancel_request" + )); + assert!(session_rx.try_recv().is_err()); + + exit.notify_one(); + connection.shutdown().await; + } + + #[tokio::test] + async fn http_connection_does_not_retain_all_outbound_replay() { + let exit = Arc::new(Notify::new()); + let message = + RawJsonRpcMessage::notification("test/method".to_string(), serde_json::json!({})) + .unwrap(); + let registry = ConnectionRegistry::new(Arc::new(SendThenWaitAgentFactory { + message, + exit: exit.clone(), + })); + let (_connection_id, connection) = registry.create_connection().await; + let (_connection_replay, mut connection_rx) = + connection.subscribe_connection_stream().await; + + connection.start_router().await; + + let text = timeout(Duration::from_secs(1), connection_rx.recv()) + .await + .unwrap() + .expect("message should reach HTTP connection stream"); + assert!(serde_json::from_str::(&text).is_ok()); + + let (all_replay, mut all_rx) = connection.subscribe_all_outbound().await; + assert!(all_replay.is_empty()); + assert!(all_rx.try_recv().is_err()); + + exit.notify_one(); + connection.shutdown().await; + } + + #[tokio::test] + async fn websocket_connection_does_not_retain_http_stream_replay() { + let exit = Arc::new(Notify::new()); + let message = RawJsonRpcMessage::notification( + "test/method".to_string(), + serde_json::json!({ "sessionId": "session-1" }), + ) + .unwrap(); + let registry = ConnectionRegistry::new(Arc::new(SendThenWaitAgentFactory { + message, + exit: exit.clone(), + })); + let connection = registry + .create_websocket_connection_with_id("conn-1".to_string()) + .await; + let (_all_replay, mut all_rx) = connection.subscribe_all_outbound().await; + + connection.start_router().await; + + let text = timeout(Duration::from_secs(1), all_rx.recv()) + .await + .unwrap() + .expect("message should reach WebSocket all-outbound stream"); + assert!(serde_json::from_str::(&text).is_ok()); + + let (connection_replay, mut connection_rx) = connection.subscribe_connection_stream().await; + let (session_replay, mut session_rx) = + connection.subscribe_session_stream("session-1").await; + assert!(connection_replay.is_empty()); + assert!(session_replay.is_empty()); + assert!(connection_rx.try_recv().is_err()); + assert!(session_rx.try_recv().is_err()); + + exit.notify_one(); + connection.shutdown().await; + } +} diff --git a/src/agent-client-protocol-http/src/http_server.rs b/src/agent-client-protocol-http/src/http_server.rs new file mode 100644 index 00000000..d22c890a --- /dev/null +++ b/src/agent-client-protocol-http/src/http_server.rs @@ -0,0 +1,690 @@ +use std::{convert::Infallible, error::Error as _, sync::Arc, time::Duration}; + +use agent_client_protocol::{RawJsonRpcMessage, schema::Response as RpcResponse}; +use axum::{ + body::Body, + extract::State, + http::{HeaderMap, HeaderValue, Request, StatusCode, header}, + response::{IntoResponse, Response, Sse, sse::Event}, +}; +use tracing::{error, info, trace}; + +use crate::{ + connection::{Connection, ConnectionRegistry, ResponseRoute}, + protocol::{ + EVENT_STREAM_MIME_TYPE, HEADER_CONNECTION_ID, HEADER_SESSION_ID, JSON_MIME_TYPE, + apply_session_header_to_message, is_initialize_request, method_for_message, + method_requires_session_header, session_id_from_message, + }, +}; + +const MAX_POST_BODY_BYTES: usize = 16 * 1024 * 1024; + +pub(crate) async fn handle_post( + State(registry): State>, + request: Request, +) -> Response { + if !request + .headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|ct| ct.starts_with(JSON_MIME_TYPE)) + { + return ( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "Content-Type must be application/json", + ) + .into_response(); + } + + let connection_id = header_value(request.headers(), HEADER_CONNECTION_ID); + let session_id = header_value(request.headers(), HEADER_SESSION_ID); + if content_length_exceeds_limit(request.headers()) { + return post_body_too_large_response(); + } + + let body = match axum::body::to_bytes(request.into_body(), MAX_POST_BODY_BYTES).await { + Ok(body) => body, + Err(e) => { + error!("Failed to read request body: {e}"); + if is_body_limit_error(&e) { + return post_body_too_large_response(); + } + return StatusCode::BAD_REQUEST.into_response(); + } + }; + + if matches!(body.first(), Some(&b'[')) { + return StatusCode::NOT_IMPLEMENTED.into_response(); + } + + let mut message = match serde_json::from_slice::(&body) { + Ok(message) => message, + Err(e) => { + return (StatusCode::BAD_REQUEST, format!("Invalid JSON-RPC: {e}")).into_response(); + } + }; + + if is_initialize_request(&message) { + let (connection_id, connection) = registry.create_connection().await; + let initialize_cleanup = + InitializeCleanup::new(registry.clone(), connection_id.clone(), connection.clone()); + if connection.send_to_agent(message).is_err() { + initialize_cleanup.cleanup().await; + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + + let Some(init_response) = connection.recv_initial().await else { + initialize_cleanup.cleanup().await; + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "agent closed before initialize response", + ) + .into_response(); + }; + let initialize_failed = matches!( + init_response, + RawJsonRpcMessage::Response(RpcResponse::Error { .. }) + ); + let init_response = match serde_json::to_string(&init_response) { + Ok(response) => response, + Err(e) => { + initialize_cleanup.cleanup().await; + error!("failed to serialize initialize response: {e}"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + if initialize_failed { + initialize_cleanup.cleanup().await; + info!(connection_id = %connection_id, "Initialize rejected"); + return json_response(init_response); + } + + connection.start_router().await; + initialize_cleanup.disarm(); + info!(connection_id = %connection_id, "Initialize complete"); + return with_connection_header(json_response(init_response), &connection_id); + } + + let Some(connection_id) = connection_id else { + return (StatusCode::BAD_REQUEST, "Acp-Connection-Id header required").into_response(); + }; + let Some(connection) = registry.get(&connection_id).await else { + return StatusCode::NOT_FOUND.into_response(); + }; + + if let Some(session_id) = &session_id + && method_for_message(&message).is_some() + && let Err(error) = apply_session_header_to_message(&mut message, session_id) + { + return (StatusCode::BAD_REQUEST, error).into_response(); + } + + let route = match method_for_message(&message) { + Some(method) => match session_id_from_message(&message) { + Some(session_id) => Some(ResponseRoute::Session(session_id)), + None if method_requires_session_header(method) => { + return (StatusCode::BAD_REQUEST, "Acp-Session-Id header required").into_response(); + } + None => Some(ResponseRoute::Connection), + }, + None => None, + }; + + if let Some(ResponseRoute::Session(session_id)) = &route { + connection.ensure_session(session_id).await; + } + if let (RawJsonRpcMessage::Request(req), Some(route)) = (&message, route) { + connection.record_pending_route(req.id.clone(), route).await; + trace!(connection_id = %connection_id, method = %req.method, "POST → agent"); + } else { + trace!(connection_id = %connection_id, ?message, "POST → agent"); + } + + if connection.send_to_agent(message).is_err() { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + StatusCode::ACCEPTED.into_response() +} + +struct InitializeCleanup { + registry: Option>, + connection_id: String, + connection: Arc, +} + +impl InitializeCleanup { + fn new( + registry: Arc, + connection_id: String, + connection: Arc, + ) -> Self { + Self { + registry: Some(registry), + connection_id, + connection, + } + } + + async fn cleanup(mut self) { + self.cleanup_inner().await; + } + + fn disarm(mut self) { + self.registry.take(); + } + + async fn cleanup_inner(&mut self) { + let Some(registry) = self.registry.take() else { + return; + }; + registry.remove(&self.connection_id).await; + self.connection.shutdown().await; + } +} + +impl Drop for InitializeCleanup { + fn drop(&mut self) { + let Some(registry) = self.registry.take() else { + return; + }; + let connection_id = self.connection_id.clone(); + let connection = self.connection.clone(); + tokio::spawn(async move { + registry.remove(&connection_id).await; + connection.shutdown().await; + }); + } +} + +pub(crate) async fn handle_get( + registry: Arc, + request: Request, +) -> Response { + if !request + .headers() + .get(header::ACCEPT) + .and_then(|v| v.to_str().ok()) + .is_some_and(|accept| accept.contains(EVENT_STREAM_MIME_TYPE)) + { + return ( + StatusCode::NOT_ACCEPTABLE, + "client must accept text/event-stream", + ) + .into_response(); + } + + let Some(connection_id) = header_value(request.headers(), HEADER_CONNECTION_ID) else { + return (StatusCode::BAD_REQUEST, "Acp-Connection-Id header required").into_response(); + }; + let Some(connection) = registry.get(&connection_id).await else { + return StatusCode::NOT_FOUND.into_response(); + }; + + let session_id = header_value(request.headers(), HEADER_SESSION_ID); + let (replay, mut receiver) = match session_id.as_deref() { + Some(session_id) => connection.subscribe_session_stream(session_id).await, + None => connection.subscribe_connection_stream().await, + }; + let mut closed = connection.subscribe_closed(); + let stream = async_stream::stream! { + for msg in replay { + trace!(payload = %msg, "SSE → client (replay)"); + yield Ok::<_, Infallible>(Event::default().data(msg)); + } + loop { + if *closed.borrow() { + break; + } + tokio::select! { + recv = receiver.recv() => match recv { + Some(msg) => { + trace!(payload = %msg, "SSE → client"); + yield Ok(Event::default().data(msg)); + } + None => break, + }, + changed = closed.changed() => { + if changed.is_err() || *closed.borrow() { + break; + } + } + } + } + }; + + let mut response = with_connection_header( + Sse::new(stream) + .keep_alive( + axum::response::sse::KeepAlive::new() + .interval(Duration::from_secs(15)) + .text(""), + ) + .into_response(), + &connection_id, + ); + if let Some(session_id) = session_id + && let Ok(value) = HeaderValue::from_str(&session_id) + { + response.headers_mut().insert(HEADER_SESSION_ID, value); + } + response +} + +pub(crate) async fn handle_delete( + State(registry): State>, + request: Request, +) -> Response { + let Some(connection_id) = header_value(request.headers(), HEADER_CONNECTION_ID) else { + return (StatusCode::BAD_REQUEST, "Acp-Connection-Id header required").into_response(); + }; + let Some(connection) = registry.remove(&connection_id).await else { + return StatusCode::NOT_FOUND.into_response(); + }; + connection.shutdown().await; + info!(connection_id = %connection_id, "Connection terminated via DELETE"); + StatusCode::ACCEPTED.into_response() +} + +fn header_value(headers: &HeaderMap, name: &str) -> Option { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .map(String::from) +} + +fn with_connection_header(mut response: Response, connection_id: &str) -> Response { + if let Ok(value) = HeaderValue::from_str(connection_id) { + response.headers_mut().insert(HEADER_CONNECTION_ID, value); + } + response +} + +fn json_response(body: String) -> Response { + ( + StatusCode::OK, + [(header::CONTENT_TYPE, JSON_MIME_TYPE)], + body, + ) + .into_response() +} + +fn content_length_exceeds_limit(headers: &HeaderMap) -> bool { + headers + .get(header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .is_some_and(|length| length > MAX_POST_BODY_BYTES) +} + +fn is_body_limit_error(error: &axum::Error) -> bool { + let mut source = error.source(); + while let Some(error) = source { + if error.to_string() == "length limit exceeded" { + return true; + } + source = error.source(); + } + false +} + +fn post_body_too_large_response() -> Response { + (StatusCode::PAYLOAD_TOO_LARGE, "POST body too large").into_response() +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use agent_client_protocol::{Channel, RawJsonRpcMessage, schema::RequestId}; + use futures::{StreamExt, future::BoxFuture}; + use serde_json::json; + use tokio::{ + sync::mpsc, + time::{Duration, sleep, timeout}, + }; + + use super::*; + use crate::connection::{AgentFactory, OUTBOUND_STREAM_CAPACITY}; + + struct CapturingAgentFactory { + forwarded: mpsc::UnboundedSender, + } + + impl AgentFactory for CapturingAgentFactory { + fn spawn_agent( + &self, + ) -> ( + Channel, + BoxFuture<'static, agent_client_protocol::Result<()>>, + ) { + let (agent, transport) = Channel::duplex(); + let forwarded = self.forwarded.clone(); + let future = Box::pin(async move { + let Channel { + rx: mut incoming, + tx: _, + } = agent; + while let Some(message) = incoming.next().await { + if forwarded.send(message?).is_err() { + break; + } + } + Ok(()) + }); + + (transport, future) + } + } + + struct RejectingInitializeAgentFactory; + + impl AgentFactory for RejectingInitializeAgentFactory { + fn spawn_agent( + &self, + ) -> ( + Channel, + BoxFuture<'static, agent_client_protocol::Result<()>>, + ) { + let (mut agent, transport) = Channel::duplex(); + let future = Box::pin(async move { + if let Some(Ok(RawJsonRpcMessage::Request(request))) = agent.rx.next().await { + agent + .tx + .unbounded_send(Ok(RawJsonRpcMessage::response( + request.id, + Err(agent_client_protocol::Error::invalid_request() + .data("initialize rejected")), + ))) + .unwrap(); + } + std::future::pending::>().await + }); + + (transport, future) + } + } + + struct PendingInitializeAgentFactory; + + impl AgentFactory for PendingInitializeAgentFactory { + fn spawn_agent( + &self, + ) -> ( + Channel, + BoxFuture<'static, agent_client_protocol::Result<()>>, + ) { + let (agent, transport) = Channel::duplex(); + let future = Box::pin(async move { + let Channel { + rx: mut incoming, + tx: _outgoing, + } = agent; + drop(incoming.next().await); + std::future::pending::>().await + }); + + (transport, future) + } + } + + #[tokio::test] + async fn post_rejects_declared_body_larger_than_limit() { + let (forwarded_tx, _forwarded_rx) = mpsc::unbounded_channel(); + let registry = Arc::new(ConnectionRegistry::new(Arc::new(CapturingAgentFactory { + forwarded: forwarded_tx, + }))); + let request = Request::builder() + .method("POST") + .uri("/acp") + .header(header::CONTENT_TYPE, JSON_MIME_TYPE) + .header( + header::CONTENT_LENGTH, + (MAX_POST_BODY_BYTES + 1).to_string(), + ) + .body(Body::from("{}")) + .unwrap(); + + let response = handle_post(State(registry), request).await; + + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + } + + #[tokio::test] + async fn initialize_error_response_rejects_connection() { + let registry = Arc::new(ConnectionRegistry::new(Arc::new( + RejectingInitializeAgentFactory, + ))); + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {} + }) + .to_string(); + let request = Request::builder() + .method("POST") + .uri("/acp") + .header(header::CONTENT_TYPE, JSON_MIME_TYPE) + .body(Body::from(body)) + .unwrap(); + + let response = handle_post(State(registry.clone()), request).await; + + assert_eq!(response.status(), StatusCode::OK); + assert!(response.headers().get(HEADER_CONNECTION_ID).is_none()); + assert_eq!(registry.len().await, 0); + let body = axum::body::to_bytes(response.into_body(), 1024) + .await + .unwrap(); + let message = serde_json::from_slice::(&body).unwrap(); + assert!(matches!( + message, + RawJsonRpcMessage::Response(RpcResponse::Error { + id: RequestId::Number(1), + .. + }) + )); + } + + #[tokio::test] + async fn cancelled_initialize_cleans_up_connection() { + let registry = Arc::new(ConnectionRegistry::new(Arc::new( + PendingInitializeAgentFactory, + ))); + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {} + }) + .to_string(); + let request = Request::builder() + .method("POST") + .uri("/acp") + .header(header::CONTENT_TYPE, JSON_MIME_TYPE) + .body(Body::from(body)) + .unwrap(); + + { + let initialize = handle_post(State(registry.clone()), request); + tokio::pin!(initialize); + timeout(Duration::from_secs(1), async { + loop { + tokio::select! { + response = &mut initialize => { + panic!( + "initialize completed unexpectedly with {}", + response.status() + ); + } + () = sleep(Duration::from_millis(10)) => { + if registry.len().await == 1 { + break; + } + } + } + } + }) + .await + .unwrap(); + } + + timeout(Duration::from_secs(1), async { + loop { + if registry.len().await == 0 { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn sse_closes_slow_subscriber_before_skipping_messages() { + let (forwarded_tx, _forwarded_rx) = mpsc::unbounded_channel(); + let registry = Arc::new(ConnectionRegistry::new(Arc::new(CapturingAgentFactory { + forwarded: forwarded_tx, + }))); + let (connection_id, connection) = registry.create_connection().await; + let request = Request::builder() + .method("GET") + .uri("/acp") + .header(header::ACCEPT, EVENT_STREAM_MIME_TYPE) + .header(HEADER_CONNECTION_ID, connection_id.as_str()) + .body(Body::empty()) + .unwrap(); + let response = handle_get(registry, request).await; + assert_eq!(response.status(), StatusCode::OK); + + for i in 0..=OUTBOUND_STREAM_CAPACITY { + connection + .push_connection_stream_for_test(format!("message-{i}")) + .await; + } + + let body = timeout( + Duration::from_secs(1), + axum::body::to_bytes(response.into_body(), 1024 * 1024), + ) + .await + .unwrap() + .unwrap(); + let body = String::from_utf8(body.to_vec()).unwrap(); + assert!(body.contains("message-0")); + assert!(body.contains(&format!("message-{}", OUTBOUND_STREAM_CAPACITY - 1))); + assert!(!body.contains(&format!("message-{OUTBOUND_STREAM_CAPACITY}"))); + + connection.shutdown().await; + } + + #[tokio::test] + async fn post_forwards_header_session_id_to_agent_params() { + let (forwarded_tx, mut forwarded_rx) = mpsc::unbounded_channel(); + let registry = Arc::new(ConnectionRegistry::new(Arc::new(CapturingAgentFactory { + forwarded: forwarded_tx, + }))); + let (connection_id, connection) = registry.create_connection().await; + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "session/prompt", + "params": { "prompt": [] } + }) + .to_string(); + let request = Request::builder() + .method("POST") + .uri("/acp") + .header(header::CONTENT_TYPE, JSON_MIME_TYPE) + .header(HEADER_CONNECTION_ID, connection_id.as_str()) + .header(HEADER_SESSION_ID, "session-1") + .body(Body::from(body)) + .unwrap(); + + let response = handle_post(State(registry), request).await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + let forwarded = timeout(Duration::from_secs(1), forwarded_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!( + session_id_from_message(&forwarded).as_deref(), + Some("session-1") + ); + connection.shutdown().await; + } + + #[tokio::test] + async fn post_does_not_apply_session_header_to_cancel_request() { + let (forwarded_tx, mut forwarded_rx) = mpsc::unbounded_channel(); + let registry = Arc::new(ConnectionRegistry::new(Arc::new(CapturingAgentFactory { + forwarded: forwarded_tx, + }))); + let (connection_id, connection) = registry.create_connection().await; + let body = json!({ + "jsonrpc": "2.0", + "method": "$/cancel_request", + "params": { "requestId": 1 } + }) + .to_string(); + let request = Request::builder() + .method("POST") + .uri("/acp") + .header(header::CONTENT_TYPE, JSON_MIME_TYPE) + .header(HEADER_CONNECTION_ID, connection_id.as_str()) + .header(HEADER_SESSION_ID, "session-1") + .body(Body::from(body)) + .unwrap(); + + let response = handle_post(State(registry), request).await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + let forwarded = timeout(Duration::from_secs(1), forwarded_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(session_id_from_message(&forwarded), None); + let value = serde_json::to_value(forwarded).unwrap(); + assert_eq!(value["params"], json!({ "requestId": 1 })); + connection.shutdown().await; + } + + #[tokio::test] + async fn post_rejects_session_scoped_method_without_session_id() { + let (forwarded_tx, mut forwarded_rx) = mpsc::unbounded_channel(); + let registry = Arc::new(ConnectionRegistry::new(Arc::new(CapturingAgentFactory { + forwarded: forwarded_tx, + }))); + let (connection_id, connection) = registry.create_connection().await; + + for method in ["session/delete", "session/fork"] { + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": {} + }) + .to_string(); + let request = Request::builder() + .method("POST") + .uri("/acp") + .header(header::CONTENT_TYPE, JSON_MIME_TYPE) + .header(HEADER_CONNECTION_ID, connection_id.as_str()) + .body(Body::from(body)) + .unwrap(); + + let response = handle_post(State(registry.clone()), request).await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{method}"); + let body = axum::body::to_bytes(response.into_body(), 1024) + .await + .unwrap(); + assert_eq!(body.as_ref(), b"Acp-Session-Id header required", "{method}"); + assert!(forwarded_rx.try_recv().is_err(), "{method}"); + } + connection.shutdown().await; + } +} diff --git a/src/agent-client-protocol-http/src/lib.rs b/src/agent-client-protocol-http/src/lib.rs new file mode 100644 index 00000000..854c12a5 --- /dev/null +++ b/src/agent-client-protocol-http/src/lib.rs @@ -0,0 +1,19 @@ +#![cfg_attr(docsrs, feature(doc_cfg))] + +#[cfg(feature = "client")] +mod client; +#[cfg(feature = "server")] +mod connection; +#[cfg(feature = "server")] +mod http_server; +#[cfg(any(feature = "client", feature = "server"))] +mod protocol; +#[cfg(feature = "server")] +mod server; +#[cfg(feature = "server")] +mod websocket_server; + +#[cfg(feature = "client")] +pub use client::{HttpClient, HttpClientError}; +#[cfg(feature = "server")] +pub use server::{AcpHttpServer, CorsOptions, ServerOptions}; diff --git a/src/agent-client-protocol-http/src/protocol.rs b/src/agent-client-protocol-http/src/protocol.rs new file mode 100644 index 00000000..1c746cf9 --- /dev/null +++ b/src/agent-client-protocol-http/src/protocol.rs @@ -0,0 +1,257 @@ +use agent_client_protocol::{RawJsonRpcMessage, RawJsonRpcParams}; + +pub(crate) const HEADER_CONNECTION_ID: &str = "acp-connection-id"; +pub(crate) const HEADER_SESSION_ID: &str = "acp-session-id"; +#[cfg(feature = "server")] +pub(crate) const EVENT_STREAM_MIME_TYPE: &str = "text/event-stream"; +#[cfg(feature = "server")] +pub(crate) const JSON_MIME_TYPE: &str = "application/json"; + +pub(crate) fn method_requires_session_header(method: &str) -> bool { + matches!( + method, + "session/prompt" + | "session/cancel" + | "session/close" + | "session/delete" + | "session/fork" + | "session/load" + | "session/resume" + | "session/set_config_option" + | "session/set_mode" + | "session/set_model" + ) +} + +pub(crate) fn is_initialize_request(msg: &RawJsonRpcMessage) -> bool { + matches!(msg, RawJsonRpcMessage::Request(req) if req.method.as_ref() == "initialize") +} + +pub(crate) fn method_for_message(msg: &RawJsonRpcMessage) -> Option<&str> { + match msg { + RawJsonRpcMessage::Request(req) => Some(req.method.as_ref()), + RawJsonRpcMessage::Notification(notification) => Some(notification.method.as_ref()), + RawJsonRpcMessage::Response(_) => None, + } +} + +pub(crate) fn is_connection_scoped_protocol_message(msg: &RawJsonRpcMessage) -> bool { + method_for_message(msg).is_some_and(|method| method.starts_with("$/")) + || is_cancel_request_message(msg) +} + +#[cfg(feature = "unstable_cancel_request")] +fn is_cancel_request_message(msg: &RawJsonRpcMessage) -> bool { + let RawJsonRpcMessage::Notification(notification) = msg else { + return false; + }; + let params = notification + .params + .clone() + .map_or(serde_json::Value::Null, RawJsonRpcParams::into_value); + let Ok(notification) = + agent_client_protocol::UntypedMessage::new(notification.method.as_ref(), params) + else { + return false; + }; + agent_client_protocol::is_cancel_request_notification(¬ification) +} + +#[cfg(not(feature = "unstable_cancel_request"))] +fn is_cancel_request_message(_msg: &RawJsonRpcMessage) -> bool { + false +} + +pub(crate) fn session_id_from_params(params: &RawJsonRpcParams) -> Option { + match params { + RawJsonRpcParams::Object(map) => map + .get("sessionId") + .and_then(|v| v.as_str()) + .map(String::from), + RawJsonRpcParams::Array(_) => None, + } +} + +pub(crate) fn session_id_from_message(msg: &RawJsonRpcMessage) -> Option { + if is_connection_scoped_protocol_message(msg) { + return None; + } + + match msg { + RawJsonRpcMessage::Request(req) => req.params.as_ref().and_then(session_id_from_params), + RawJsonRpcMessage::Notification(notification) => notification + .params + .as_ref() + .and_then(session_id_from_params), + RawJsonRpcMessage::Response(_) => None, + } +} + +#[cfg(feature = "server")] +pub(crate) fn apply_session_header_to_message( + msg: &mut RawJsonRpcMessage, + session_id: &str, +) -> Result<(), &'static str> { + if is_connection_scoped_protocol_message(msg) { + return Ok(()); + } + + match msg { + RawJsonRpcMessage::Request(req) => { + apply_session_header_to_params(&mut req.params, session_id) + } + RawJsonRpcMessage::Notification(notification) => { + apply_session_header_to_params(&mut notification.params, session_id) + } + RawJsonRpcMessage::Response(_) => Ok(()), + } +} + +#[cfg(feature = "server")] +fn apply_session_header_to_params( + params: &mut Option, + session_id: &str, +) -> Result<(), &'static str> { + match params { + Some(RawJsonRpcParams::Object(map)) => { + match map.get("sessionId") { + Some(serde_json::Value::String(existing)) if existing == session_id => {} + Some(serde_json::Value::String(_)) => { + return Err("Acp-Session-Id header does not match params.sessionId"); + } + Some(_) => return Err("params.sessionId must be a string"), + None => { + map.insert( + "sessionId".to_string(), + serde_json::Value::String(session_id.to_string()), + ); + } + } + Ok(()) + } + Some(RawJsonRpcParams::Array(_)) => Err("Acp-Session-Id header requires object params"), + None => { + let mut map = serde_json::Map::new(); + map.insert( + "sessionId".to_string(), + serde_json::Value::String(session_id.to_string()), + ); + *params = Some(RawJsonRpcParams::Object(map)); + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + #[cfg(feature = "server")] + use agent_client_protocol::schema::RequestId; + use axum::http::{HeaderMap, HeaderValue}; + #[cfg(feature = "server")] + use serde_json::json; + + use super::*; + + #[test] + fn custom_response_headers_are_valid_static_header_names() { + let mut headers = HeaderMap::new(); + + headers.insert(HEADER_CONNECTION_ID, HeaderValue::from_static("conn-1")); + headers.insert(HEADER_SESSION_ID, HeaderValue::from_static("session-1")); + + assert_eq!(headers[HEADER_CONNECTION_ID], "conn-1"); + assert_eq!(headers[HEADER_SESSION_ID], "session-1"); + } + + #[cfg(feature = "server")] + #[test] + fn session_header_is_inserted_into_object_params() { + let mut message = RawJsonRpcMessage::request( + "session/prompt".to_string(), + json!({ "prompt": [] }), + RequestId::Number(1), + ) + .unwrap(); + + apply_session_header_to_message(&mut message, "session-1").unwrap(); + + assert_eq!( + session_id_from_message(&message).as_deref(), + Some("session-1") + ); + } + + #[cfg(feature = "server")] + #[test] + fn session_header_conflict_is_rejected() { + let mut message = RawJsonRpcMessage::request( + "session/prompt".to_string(), + json!({ "sessionId": "session-1" }), + RequestId::Number(1), + ) + .unwrap(); + + let error = apply_session_header_to_message(&mut message, "session-2").unwrap_err(); + + assert_eq!( + error, + "Acp-Session-Id header does not match params.sessionId" + ); + } + + #[cfg(feature = "server")] + #[test] + fn protocol_level_message_ignores_session_header() { + let mut message = RawJsonRpcMessage::notification( + "$/cancel_request".to_string(), + json!({ "requestId": 1 }), + ) + .unwrap(); + + apply_session_header_to_message(&mut message, "session-1").unwrap(); + + assert_eq!(session_id_from_message(&message), None); + let value = serde_json::to_value(message).unwrap(); + assert!(value["params"].get("sessionId").is_none()); + } + + #[cfg(all(feature = "server", feature = "unstable_cancel_request"))] + #[test] + fn successor_wrapped_cancel_request_ignores_session_header() { + let mut message = RawJsonRpcMessage::notification( + "_proxy/successor".to_string(), + json!({ + "method": "$/cancel_request", + "params": { "requestId": 1 } + }), + ) + .unwrap(); + + apply_session_header_to_message(&mut message, "session-1").unwrap(); + + assert_eq!(session_id_from_message(&message), None); + let value = serde_json::to_value(message).unwrap(); + assert!(value["params"].get("sessionId").is_none()); + } + + #[test] + fn all_session_scoped_client_methods_require_session_header() { + for method in [ + "session/cancel", + "session/close", + "session/delete", + "session/fork", + "session/load", + "session/prompt", + "session/resume", + "session/set_config_option", + "session/set_mode", + "session/set_model", + ] { + assert!( + method_requires_session_header(method), + "{method} should require Acp-Session-Id or params.sessionId" + ); + } + } +} diff --git a/src/agent-client-protocol-http/src/server.rs b/src/agent-client-protocol-http/src/server.rs new file mode 100644 index 00000000..a3109973 --- /dev/null +++ b/src/agent-client-protocol-http/src/server.rs @@ -0,0 +1,230 @@ +use std::sync::Arc; + +use agent_client_protocol::{Client, ConnectTo}; +use axum::{ + Router, + extract::WebSocketUpgrade, + extract::ws::rejection::WebSocketUpgradeRejection, + http::{HeaderName, HeaderValue, Method, StatusCode, header, header::InvalidHeaderValue}, + response::{IntoResponse, Response}, + routing::{delete, get, post}, +}; +use tower_http::cors::{AllowOrigin, CorsLayer}; + +use crate::connection::ConnectionRegistry; + +#[derive(Debug, Clone)] +pub struct ServerOptions { + pub path: String, + pub cors: CorsOptions, + pub health_endpoint: bool, +} + +impl Default for ServerOptions { + fn default() -> Self { + Self { + path: "/acp".to_string(), + cors: CorsOptions::default(), + health_endpoint: true, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum CorsOptions { + #[default] + Disabled, + AllowOrigins(Vec), + AllowAnyOrigin, +} + +impl CorsOptions { + #[must_use] + pub fn disabled() -> Self { + Self::Disabled + } + + #[must_use] + pub fn allow_any_origin() -> Self { + Self::AllowAnyOrigin + } + + pub fn allow_origins(origins: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + origins + .into_iter() + .map(|origin| HeaderValue::from_str(origin.as_ref())) + .collect::, _>>() + .map(Self::AllowOrigins) + } + + fn allow_origin_layer(&self) -> Option { + match self { + Self::Disabled => None, + Self::AllowOrigins(origins) => Some(AllowOrigin::list(origins.clone())), + Self::AllowAnyOrigin => Some(AllowOrigin::mirror_request()), + } + } + + fn allows_origin(&self, origin: Option<&HeaderValue>) -> bool { + let Some(origin) = origin else { + return true; + }; + match self { + Self::Disabled => false, + Self::AllowOrigins(origins) => origins.iter().any(|allowed| allowed == origin), + Self::AllowAnyOrigin => true, + } + } +} + +#[derive(Clone)] +struct ServerState { + registry: Arc, + cors: CorsOptions, +} + +pub struct AcpHttpServer { + registry: Arc, + options: ServerOptions, +} + +impl std::fmt::Debug for AcpHttpServer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AcpHttpServer") + .field("options", &self.options) + .finish_non_exhaustive() + } +} + +impl AcpHttpServer { + pub fn new(factory: F) -> Self + where + F: Fn() -> C + Send + Sync + 'static, + C: ConnectTo, + { + Self { + registry: Arc::new(ConnectionRegistry::new(Arc::new(factory))), + options: ServerOptions::default(), + } + } + + #[must_use] + pub fn with_options(mut self, options: ServerOptions) -> Self { + self.options = options; + self + } + + pub fn into_router(self) -> Router { + let registry = self.registry.clone(); + let path = self.options.path.clone(); + let cors = self.options.cors.clone(); + let state = ServerState { + registry: registry.clone(), + cors: cors.clone(), + }; + + let mut router = Router::new() + .route( + &path, + post(crate::http_server::handle_post).with_state(registry.clone()), + ) + .route(&path, get(handle_get).with_state(state)) + .route( + &path, + delete(crate::http_server::handle_delete).with_state(registry), + ); + + if self.options.health_endpoint { + router = router.route("/health", get(health)); + } + + if let Some(allow_origin) = cors.allow_origin_layer() { + router = router.layer(default_cors(allow_origin)); + } + + router + } +} + +async fn health() -> &'static str { + "ok" +} + +fn default_cors(allow_origin: AllowOrigin) -> CorsLayer { + CorsLayer::new() + .allow_origin(allow_origin) + .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS]) + .allow_headers([ + header::CONTENT_TYPE, + header::ACCEPT, + HeaderName::from_static("acp-connection-id"), + HeaderName::from_static("acp-session-id"), + header::SEC_WEBSOCKET_VERSION, + header::SEC_WEBSOCKET_KEY, + header::CONNECTION, + header::UPGRADE, + ]) + .expose_headers([ + HeaderName::from_static("acp-connection-id"), + HeaderName::from_static("acp-session-id"), + ]) +} + +async fn handle_get( + ws_upgrade: Result, + axum::extract::State(state): axum::extract::State, + request: axum::http::Request, +) -> Response { + match ws_upgrade { + Ok(ws) => { + if !state + .cors + .allows_origin(request.headers().get(header::ORIGIN)) + { + return (StatusCode::FORBIDDEN, "WebSocket origin not allowed").into_response(); + } + crate::websocket_server::handle_ws_upgrade(state.registry, ws) + } + Err(_) => crate::http_server::handle_get(state.registry, request).await, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cors_is_disabled_by_default() { + assert_eq!(ServerOptions::default().cors, CorsOptions::Disabled); + } + + #[test] + fn disabled_cors_rejects_browser_origin_for_websockets() { + let origin = HeaderValue::from_static("http://localhost:5173"); + + assert!(CorsOptions::disabled().allows_origin(None)); + assert!(!CorsOptions::disabled().allows_origin(Some(&origin))); + } + + #[test] + fn cors_allowlist_matches_configured_origins() { + let allowed = HeaderValue::from_static("http://localhost:5173"); + let denied = HeaderValue::from_static("http://localhost:3000"); + let cors = CorsOptions::allow_origins(["http://localhost:5173"]).unwrap(); + + assert!(cors.allows_origin(None)); + assert!(cors.allows_origin(Some(&allowed))); + assert!(!cors.allows_origin(Some(&denied))); + } + + #[test] + fn explicit_allow_any_origin_accepts_browser_origins() { + let origin = HeaderValue::from_static("https://example.com"); + + assert!(CorsOptions::allow_any_origin().allows_origin(Some(&origin))); + } +} diff --git a/src/agent-client-protocol-http/src/websocket_server.rs b/src/agent-client-protocol-http/src/websocket_server.rs new file mode 100644 index 00000000..279d6ee3 --- /dev/null +++ b/src/agent-client-protocol-http/src/websocket_server.rs @@ -0,0 +1,279 @@ +use std::sync::Arc; + +use agent_client_protocol::{Error as AcpError, RawJsonRpcMessage, schema::RequestId}; +use axum::{ + extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}, + http::HeaderValue, + response::Response, +}; +use futures::{SinkExt, StreamExt}; +use tracing::{debug, error, info, trace, warn}; + +use crate::{ + connection::ConnectionRegistry, + protocol::{HEADER_CONNECTION_ID, session_id_from_message}, +}; + +pub(crate) fn handle_ws_upgrade( + registry: Arc, + ws: WebSocketUpgrade, +) -> Response { + let connection_id = ConnectionRegistry::next_connection_id(); + let conn_id_for_handler = connection_id.clone(); + let registry_for_handler = registry.clone(); + let mut response = ws.on_upgrade(move |socket| async move { + let connection = registry_for_handler + .create_websocket_connection_with_id(conn_id_for_handler.clone()) + .await; + connection.start_router().await; + info!(connection_id = %conn_id_for_handler, "WebSocket connection created"); + run_ws( + socket, + registry_for_handler, + conn_id_for_handler, + connection, + ) + .await; + }); + + if let Ok(v) = HeaderValue::from_str(&connection_id) { + response.headers_mut().insert(HEADER_CONNECTION_ID, v); + } + response +} + +async fn run_ws( + socket: WebSocket, + registry: Arc, + connection_id: String, + connection: Arc, +) { + let (mut ws_tx, mut ws_rx) = socket.split(); + let (replay, mut outbound_rx) = connection.subscribe_all_outbound().await; + let mut closed = connection.subscribe_closed(); + + debug!(connection_id = %connection_id, "Starting WebSocket message loop"); + + for text in replay { + trace!(connection_id = %connection_id, payload = %text, "Agent → Client (replay): {} bytes", text.len()); + if ws_tx.send(WsMessage::Text(text.into())).await.is_err() { + error!(connection_id = %connection_id, "WebSocket send failed during replay"); + if let Some(conn) = registry.remove(&connection_id).await { + conn.shutdown().await; + } + return; + } + } + + loop { + if *closed.borrow() { + break; + } + tokio::select! { + msg_result = ws_rx.next() => { + match msg_result { + Some(Ok(WsMessage::Text(text))) => { + let text_str = text.to_string(); + trace!(connection_id = %connection_id, payload = %text_str, "Client → Agent: {} bytes", text_str.len()); + match serde_json::from_str::(&text_str) { + Ok(parsed) => { + if let Some(sid) = session_id_from_message(&parsed) + && let RawJsonRpcMessage::Request(req) = &parsed { + trace!(connection_id = %connection_id, session_id = %sid, request_id = ?req.id, "Client → Agent (session)"); + } + if connection.send_to_agent(parsed).is_err() { + error!(connection_id = %connection_id, "Agent channel closed"); + break; + } + } + Err(e) => { + let message = format!("malformed JSON-RPC payload: {e}"); + warn!(connection_id = %connection_id, "Returning parse error for malformed JSON-RPC frame: {e}"); + let response = RawJsonRpcMessage::response( + RequestId::Null, + Err(AcpError::parse_error().data(message)), + ); + let text = match serde_json::to_string(&response) { + Ok(text) => text, + Err(e) => { + error!(connection_id = %connection_id, "Failed to serialize parse error response: {e}"); + break; + } + }; + if ws_tx.send(WsMessage::Text(text.into())).await.is_err() { + error!(connection_id = %connection_id, "WebSocket send failed"); + break; + } + } + } + } + Some(Ok(WsMessage::Close(frame))) => { + debug!(connection_id = %connection_id, "Client closed connection: {:?}", frame); + break; + } + Some(Ok(WsMessage::Ping(_) | WsMessage::Pong(_))) => {} + Some(Ok(WsMessage::Binary(_))) => { + warn!(connection_id = %connection_id, "Ignoring binary message (ACP uses text)"); + } + Some(Err(e)) => { + error!(connection_id = %connection_id, "WebSocket error: {e}"); + break; + } + None => break, + } + } + + recv = outbound_rx.recv() => { + match recv { + Some(text) => { + trace!(connection_id = %connection_id, payload = %text, "Agent → Client: {} bytes", text.len()); + if ws_tx.send(WsMessage::Text(text.into())).await.is_err() { + error!(connection_id = %connection_id, "WebSocket send failed"); + break; + } + } + None => break, + } + } + + changed = closed.changed() => { + if changed.is_err() || *closed.borrow() { + break; + } + } + } + } + + debug!(connection_id = %connection_id, "Cleaning up WebSocket connection"); + if let Some(conn) = registry.remove(&connection_id).await { + conn.shutdown().await; + } +} + +#[cfg(test)] +mod tests { + use agent_client_protocol::{ + Channel, + schema::{RequestId, Response as RpcResponse}, + }; + use async_tungstenite::{tokio::connect_async, tungstenite::Message as ClientWsMessage}; + use axum::{Router, extract::WebSocketUpgrade, routing::get}; + use futures::{StreamExt as _, future::BoxFuture}; + use serde_json::json; + use tokio::{ + net::TcpListener, + sync::mpsc, + time::{Duration, timeout}, + }; + + use crate::connection::{AgentFactory, ConnectionRegistry}; + + use super::*; + + struct CapturingAgentFactory { + forwarded: mpsc::UnboundedSender, + } + + impl AgentFactory for CapturingAgentFactory { + fn spawn_agent( + &self, + ) -> ( + Channel, + BoxFuture<'static, agent_client_protocol::Result<()>>, + ) { + let (agent, transport) = Channel::duplex(); + let forwarded = self.forwarded.clone(); + let future = Box::pin(async move { + let Channel { + rx: mut incoming, + tx: _, + } = agent; + while let Some(message) = incoming.next().await { + if forwarded.send(message?).is_err() { + break; + } + } + Ok(()) + }); + + (transport, future) + } + } + + #[tokio::test] + async fn malformed_ws_frame_returns_parse_error_response_and_continues() { + let (forwarded_tx, mut forwarded_rx) = mpsc::unbounded_channel(); + let registry = Arc::new(ConnectionRegistry::new(Arc::new(CapturingAgentFactory { + forwarded: forwarded_tx, + }))); + let app = Router::new().route( + "/acp", + get({ + let registry = registry.clone(); + move |ws: WebSocketUpgrade| { + let registry = registry.clone(); + async move { handle_ws_upgrade(registry, ws) } + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let (mut client, _) = connect_async(format!("ws://{addr}/acp")).await.unwrap(); + + client + .send(ClientWsMessage::Text("{not json".into())) + .await + .unwrap(); + + let frame = timeout(Duration::from_secs(1), client.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + let ClientWsMessage::Text(text) = frame else { + panic!("expected text frame: {frame:?}"); + }; + let value: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(value["id"], serde_json::Value::Null); + assert_eq!(value["error"]["code"], -32700); + assert!( + value["error"]["data"] + .as_str() + .unwrap() + .contains("malformed JSON-RPC payload") + ); + + let parsed = serde_json::from_value::(value).unwrap(); + assert!(matches!( + parsed, + RawJsonRpcMessage::Response(RpcResponse::Error { + id: RequestId::Null, + .. + }) + )); + + let notification = + RawJsonRpcMessage::notification("test/method".to_string(), json!({})).unwrap(); + client + .send(ClientWsMessage::Text( + serde_json::to_string(¬ification).unwrap().into(), + )) + .await + .unwrap(); + + let forwarded = timeout(Duration::from_secs(1), forwarded_rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(matches!( + forwarded, + RawJsonRpcMessage::Notification(notification) + if notification.method.as_ref() == "test/method" + )); + + server.abort(); + } +} diff --git a/src/agent-client-protocol-polyfill/Cargo.toml b/src/agent-client-protocol-polyfill/Cargo.toml index e9f824d7..a5dc9305 100644 --- a/src/agent-client-protocol-polyfill/Cargo.toml +++ b/src/agent-client-protocol-polyfill/Cargo.toml @@ -20,7 +20,7 @@ futures-concurrency.workspace = true rustc-hash.workspace = true serde_json.workspace = true thiserror = "2.0" -tokio.workspace = true +tokio = { workspace = true, features = ["net"] } tokio-util.workspace = true tracing.workspace = true uuid.workspace = true diff --git a/src/agent-client-protocol-rmcp/Cargo.toml b/src/agent-client-protocol-rmcp/Cargo.toml index 1856d6c2..be528781 100644 --- a/src/agent-client-protocol-rmcp/Cargo.toml +++ b/src/agent-client-protocol-rmcp/Cargo.toml @@ -18,7 +18,7 @@ rmcp.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true -tokio.workspace = true +tokio = { workspace = true, features = ["io-std", "io-util", "macros", "rt-multi-thread"] } tokio-util.workspace = true [dev-dependencies] diff --git a/src/agent-client-protocol-test/Cargo.toml b/src/agent-client-protocol-test/Cargo.toml index 58f39438..cc65e3a2 100644 --- a/src/agent-client-protocol-test/Cargo.toml +++ b/src/agent-client-protocol-test/Cargo.toml @@ -24,7 +24,7 @@ rmcp = { workspace = true, features = ["server", "client", "transport-child-proc schemars.workspace = true serde.workspace = true serde_json.workspace = true -tokio.workspace = true +tokio = { workspace = true, features = ["io-std", "macros", "process", "rt-multi-thread"] } tokio-util.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/src/agent-client-protocol-trace-viewer/Cargo.toml b/src/agent-client-protocol-trace-viewer/Cargo.toml index e89e3e08..b5fafb9c 100644 --- a/src/agent-client-protocol-trace-viewer/Cargo.toml +++ b/src/agent-client-protocol-trace-viewer/Cargo.toml @@ -24,7 +24,7 @@ axum.workspace = true clap.workspace = true open.workspace = true serde_json.workspace = true -tokio.workspace = true +tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread"] } [lints] workspace = true diff --git a/src/agent-client-protocol/Cargo.toml b/src/agent-client-protocol/Cargo.toml index 9a1ecc00..c6836ea0 100644 --- a/src/agent-client-protocol/Cargo.toml +++ b/src/agent-client-protocol/Cargo.toml @@ -56,7 +56,7 @@ shell-words.workspace = true agent-client-protocol-test.workspace = true clap.workspace = true expect-test.workspace = true -tokio.workspace = true +tokio = { workspace = true, features = ["io-std", "io-util", "macros", "rt", "rt-multi-thread", "sync", "time"] } tokio-util.workspace = true [lints] diff --git a/src/agent-client-protocol/src/jsonrpc.rs b/src/agent-client-protocol/src/jsonrpc.rs index 7de317c8..bb465545 100644 --- a/src/agent-client-protocol/src/jsonrpc.rs +++ b/src/agent-client-protocol/src/jsonrpc.rs @@ -3957,8 +3957,7 @@ impl SentRequest { /// cancels the incoming request, the cancellation is forwarded to the /// outgoing request, and the downstream response (normal data or a /// cancellation error) is still forwarded back. This is equivalent to - /// registering the responder's marker with - /// [`forward_cancellation_from`](Self::forward_cancellation_from). + /// registering the responder's marker with `forward_cancellation_from`. #[track_caller] pub fn forward_response_to(self, responder: Responder) -> Result<(), crate::Error> where diff --git a/src/yopo/Cargo.toml b/src/yopo/Cargo.toml index 9ed3fc2b..40326763 100644 --- a/src/yopo/Cargo.toml +++ b/src/yopo/Cargo.toml @@ -20,7 +20,7 @@ path = "src/main.rs" [dependencies] agent-client-protocol.workspace = true clap.workspace = true -tokio.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } tracing.workspace = true tracing-subscriber.workspace = true