diff --git a/Cargo.lock b/Cargo.lock index 222c62e..b387839 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,8 +71,7 @@ dependencies = [ [[package]] name = "agent-client-protocol" version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d87bc7769eba641753ba5dc52f73ec3765d51022c6753bf040967125ddc86a8" +source = "git+https://github.com/danielkov/rust-sdk.git?rev=2f039993d1d6ed8da35b38c31f54a7cbb7338c70#2f039993d1d6ed8da35b38c31f54a7cbb7338c70" dependencies = [ "agent-client-protocol-derive", "agent-client-protocol-schema", @@ -95,18 +94,16 @@ dependencies = [ [[package]] name = "agent-client-protocol-derive" version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3abd4080f51e4f24f5042beb7fb7a66ede29a2dc1c2582c329532e1c27264ddc" +source = "git+https://github.com/danielkov/rust-sdk.git?rev=2f039993d1d6ed8da35b38c31f54a7cbb7338c70#2f039993d1d6ed8da35b38c31f54a7cbb7338c70" dependencies = [ "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] name = "agent-client-protocol-http" version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b4d8db045bc66b84526dfe4ef2ffb87d651b81c4ff945f91c63a2bc677a582c" +source = "git+https://github.com/danielkov/rust-sdk.git?rev=2f039993d1d6ed8da35b38c31f54a7cbb7338c70#2f039993d1d6ed8da35b38c31f54a7cbb7338c70" dependencies = [ "agent-client-protocol", "async-stream", @@ -121,9 +118,8 @@ dependencies = [ [[package]] name = "agent-client-protocol-schema" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5c231915b4ab578c722eca2d1bd7df4d300bfd6cac3b8e9f0d1e3ddc95b187c" +version = "1.7.0" +source = "git+https://github.com/danielkov/agent-client-protocol?rev=6e7e044f9464c4fd652d90699a09e9edc8b3bbad#6e7e044f9464c4fd652d90699a09e9edc8b3bbad" dependencies = [ "anyhow", "derive_more", @@ -137,9 +133,8 @@ dependencies = [ [[package]] name = "agentkit-acp" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb7ca61845976492c4c0499fcd34b5ce4d08004086d4642f1b6183afe17c62be" +version = "0.10.9" +source = "git+https://github.com/danielkov/agentkit.git?rev=4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597#4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" dependencies = [ "agent-client-protocol", "agentkit-core", @@ -156,8 +151,7 @@ dependencies = [ [[package]] name = "agentkit-adapter-completions" version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "806dbedc87b90d56b66e1059308159b7a8c20f4012b69018d29cc22576f62ff9" +source = "git+https://github.com/danielkov/agentkit.git?rev=4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597#4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" dependencies = [ "agentkit-core", "agentkit-http", @@ -175,8 +169,7 @@ dependencies = [ [[package]] name = "agentkit-capabilities" version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193f4a771e30b61fbabecd90dba9a9bc16bdafc2cf717d086727871e9d0e3cf" +source = "git+https://github.com/danielkov/agentkit.git?rev=4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597#4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" dependencies = [ "agentkit-core", "async-trait", @@ -188,8 +181,7 @@ dependencies = [ [[package]] name = "agentkit-compaction" version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "483b8de89e4d2de33370151c398c4d81bfd3267899cdc2caee4361c9dab052f2" +source = "git+https://github.com/danielkov/agentkit.git?rev=4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597#4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" dependencies = [ "agentkit-core", "agentkit-loop", @@ -201,8 +193,7 @@ dependencies = [ [[package]] name = "agentkit-context" version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b3c5c91fed4a5ceb3afa3584877aa94304c2bccb2b383ddb964b4c0024165e8" +source = "git+https://github.com/danielkov/agentkit.git?rev=4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597#4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" dependencies = [ "agentkit-core", "async-fs", @@ -214,8 +205,7 @@ dependencies = [ [[package]] name = "agentkit-core" version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2110778950cdd87cc8ea3f0697c0551eb515ef23841b9c5d255a01974be17798" +source = "git+https://github.com/danielkov/agentkit.git?rev=4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597#4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" dependencies = [ "futures-timer", "serde", @@ -226,8 +216,7 @@ dependencies = [ [[package]] name = "agentkit-http" version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbd4da0aa486968a37043d459bbd34ffa1dfe2f46e868c7b45d9d1f328b8386c" +source = "git+https://github.com/danielkov/agentkit.git?rev=4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597#4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" dependencies = [ "async-trait", "bytes", @@ -242,8 +231,7 @@ dependencies = [ [[package]] name = "agentkit-loop" version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e80cbe20770c311af224c1218acfe6b86c1dc2ec17ab2bb69ed17d2773aa1a0" +source = "git+https://github.com/danielkov/agentkit.git?rev=4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597#4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" dependencies = [ "agentkit-core", "agentkit-task-manager", @@ -254,14 +242,13 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tracing", - "tracing-opentelemetry 0.32.0", + "tracing-opentelemetry 0.32.1", ] [[package]] name = "agentkit-mcp" version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c13599c05c4b036902b83fa22f05d0415d85b8e9ba52be6b5a50256241c8492" +source = "git+https://github.com/danielkov/agentkit.git?rev=4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597#4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" dependencies = [ "agentkit-capabilities", "agentkit-core", @@ -282,8 +269,7 @@ dependencies = [ [[package]] name = "agentkit-plugins" version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5081b285831d69d9304052ae12a3468dfb850e84732dc3ec4a726906d7592daa" +source = "git+https://github.com/danielkov/agentkit.git?rev=4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597#4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" dependencies = [ "http", "serde", @@ -296,8 +282,7 @@ dependencies = [ [[package]] name = "agentkit-provider-openrouter" version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c04da7299c93bf9c8efed898d82284c20817f18b6669367f5807f57e50c05e2" +source = "git+https://github.com/danielkov/agentkit.git?rev=4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597#4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" dependencies = [ "agentkit-adapter-completions", "agentkit-core", @@ -312,8 +297,7 @@ dependencies = [ [[package]] name = "agentkit-task-manager" version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e5783e77661abcf2739325383c5bb348b83ba8d6c066bebb47b8ba3671ba921" +source = "git+https://github.com/danielkov/agentkit.git?rev=4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597#4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" dependencies = [ "agentkit-core", "agentkit-tools-core", @@ -325,8 +309,7 @@ dependencies = [ [[package]] name = "agentkit-tool-compose" version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "099026fd6086dc317e03759b3c099ddae5ba85f8811cd50e83aff138c0fac249" +source = "git+https://github.com/danielkov/agentkit.git?rev=4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597#4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" dependencies = [ "agentkit-core", "agentkit-tools-core", @@ -356,8 +339,7 @@ dependencies = [ [[package]] name = "agentkit-tools-core" version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10b9a8af3f09c79fc0707ea87dc377edd40bdf65db11e6ebfb50783f4e8e8ff3" +source = "git+https://github.com/danielkov/agentkit.git?rev=4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597#4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" dependencies = [ "agentkit-capabilities", "agentkit-core", @@ -666,7 +648,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -864,9 +846,9 @@ dependencies = [ [[package]] name = "blocking" -version = "1.6.2" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" dependencies = [ "async-channel", "async-task", @@ -940,9 +922,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.3" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "jobserver", @@ -993,7 +975,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common 0.1.6", + "crypto-common 0.1.7", "inout", ] @@ -1028,7 +1010,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -1054,9 +1036,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -1142,9 +1124,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ "cfg-if", ] @@ -1191,9 +1173,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", @@ -1220,12 +1202,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.24.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ - "darling_core 0.24.0", - "darling_macro 0.24.0", + "darling_core 0.24.1", + "darling_macro 0.24.1", ] [[package]] @@ -1243,15 +1225,15 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.24.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -1267,13 +1249,13 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.24.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ - "darling_core 0.24.0", + "darling_core 0.24.1", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -1352,7 +1334,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "crypto-common 0.1.6", + "crypto-common 0.1.7", "subtle", ] @@ -1375,7 +1357,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -1401,9 +1383,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "email_address" @@ -1675,7 +1657,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -1715,9 +1697,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.9" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", @@ -2069,9 +2051,9 @@ checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -2157,11 +2139,11 @@ version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" dependencies = [ - "darling 0.24.0", + "darling 0.24.1", "indoc", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -2418,7 +2400,7 @@ dependencies = [ [[package]] name = "kit" -version = "0.1.91" +version = "0.1.92" dependencies = [ "a2a-protocol-client", "a2a-protocol-server", @@ -2460,7 +2442,7 @@ dependencies = [ "libc", "opentelemetry 0.32.0", "opentelemetry-otlp", - "opentelemetry_sdk 0.32.1", + "opentelemetry_sdk", "ratatui", "reqwest", "rmcp", @@ -2548,9 +2530,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru" @@ -2743,7 +2725,7 @@ dependencies = [ "chrono", "getrandom 0.2.17", "http", - "rand 0.8.7", + "rand 0.8.8", "serde", "serde_json", "serde_path_to_error", @@ -2805,7 +2787,7 @@ dependencies = [ "http", "opentelemetry 0.32.0", "opentelemetry-proto", - "opentelemetry_sdk 0.32.1", + "opentelemetry_sdk", "prost", "thiserror 2.0.18", "tokio", @@ -2820,27 +2802,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" dependencies = [ "opentelemetry 0.32.0", - "opentelemetry_sdk 0.32.1", + "opentelemetry_sdk", "prost", "tonic", "tonic-prost", ] -[[package]] -name = "opentelemetry_sdk" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" -dependencies = [ - "futures-channel", - "futures-executor", - "futures-util", - "opentelemetry 0.31.0", - "percent-encoding", - "rand 0.9.5", - "thiserror 2.0.18", -] - [[package]] name = "opentelemetry_sdk" version = "0.32.1" @@ -3191,9 +3158,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -3367,7 +3334,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -3624,9 +3591,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.14" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -3709,7 +3676,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -3812,7 +3779,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -3823,7 +3790,7 @@ checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -3859,7 +3826,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -4143,9 +4110,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -4325,7 +4292,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -4577,15 +4544,12 @@ dependencies = [ [[package]] name = "tracing-opentelemetry" -version = "0.32.0" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6e5658463dd88089aba75c7791e1d3120633b1bfde22478b28f625a9bb1b8e" +checksum = "1ac28f2d093c6c477eaa76b23525478f38de514fa9aeb1285738d4b97a9552fc" dependencies = [ "js-sys", "opentelemetry 0.31.0", - "opentelemetry_sdk 0.31.0", - "rustversion", - "thiserror 2.0.18", "tracing", "tracing-core", "tracing-subscriber", @@ -5265,7 +5229,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", "zbus_names", "zvariant", "zvariant_utils", @@ -5376,13 +5340,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -5434,7 +5398,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", "zvariant_utils", ] @@ -5447,6 +5411,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 3.0.3", + "syn 3.0.4", "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index e584a10..6475117 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kit" -version = "0.1.91" +version = "0.1.92" edition = "2024" rust-version = "1.94.0" publish = false @@ -9,9 +9,9 @@ publish = false a2a-protocol-client = "=0.9.0" a2a-protocol-server = { version = "=0.9.0", default-features = false } a2a-protocol-types = "=0.9.0" -agent-client-protocol = { version = "=2.0.0", features = ["unstable_session_fork"] } +agent-client-protocol = { version = "=2.0.0", features = ["unstable_session_fork", "unstable_session_inject"] } agent-client-protocol-http = { version = "=2.0.0", default-features = false, features = ["server"] } -agentkit-acp = "=0.10.7" +agentkit-acp = { version = "=0.10.9", features = ["unstable-inject"] } agentkit-adapter-completions = "=0.10.6" agentkit-core = "=0.10.5" agentkit-context = "=0.10.5" @@ -81,3 +81,20 @@ windows-sys = { version = "=0.61.2", features = [ [dev-dependencies] jsonwebtoken = { version = "=11.0.0", default-features = false, features = ["aws_lc_rs", "use_pem"] } tempfile = "=3.27.0" + +[patch.crates-io] +agentkit-acp = { git = "https://github.com/danielkov/agentkit.git", rev = "4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" } +agentkit-adapter-completions = { git = "https://github.com/danielkov/agentkit.git", rev = "4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" } +agentkit-core = { git = "https://github.com/danielkov/agentkit.git", rev = "4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" } +agentkit-context = { git = "https://github.com/danielkov/agentkit.git", rev = "4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" } +agentkit-compaction = { git = "https://github.com/danielkov/agentkit.git", rev = "4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" } +agentkit-loop = { git = "https://github.com/danielkov/agentkit.git", rev = "4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" } +agentkit-mcp = { git = "https://github.com/danielkov/agentkit.git", rev = "4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" } +agentkit-http = { git = "https://github.com/danielkov/agentkit.git", rev = "4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" } +agentkit-plugins = { git = "https://github.com/danielkov/agentkit.git", rev = "4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" } +agentkit-provider-openrouter = { git = "https://github.com/danielkov/agentkit.git", rev = "4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" } +agentkit-task-manager = { git = "https://github.com/danielkov/agentkit.git", rev = "4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" } +agentkit-tool-compose = { git = "https://github.com/danielkov/agentkit.git", rev = "4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" } +agentkit-tools-core = { git = "https://github.com/danielkov/agentkit.git", rev = "4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" } +agent-client-protocol = { git = "https://github.com/danielkov/rust-sdk.git", rev = "2f039993d1d6ed8da35b38c31f54a7cbb7338c70" } +agent-client-protocol-http = { git = "https://github.com/danielkov/rust-sdk.git", rev = "2f039993d1d6ed8da35b38c31f54a7cbb7338c70" } diff --git a/docs/issues/background-subagents-stuck-starting.md b/docs/issues/background-subagents-stuck-starting.md new file mode 100644 index 0000000..dbeb5c1 --- /dev/null +++ b/docs/issues/background-subagents-stuck-starting.md @@ -0,0 +1,23 @@ +# Background subagents remain in `starting` + +## Summary + +Subagents launched from one background `compose` call remained in the `starting` state indefinitely. The same subagent workflow started and completed normally when launched alone in a foreground `compose` call. + +## Environment + +- Kit harness: 0.1.86 +- Parent subagent depth: 1/2 +- Three independent `subagent` calls were launched from one `compose` program with `background: 1`. + +## Observed behavior + +The three sessions remained in `starting` for more than two minutes. Closing completed inspection sessions did not unblock them. The parent had to close the three starting sessions and relaunch each task in a foreground call. This delayed implementation and prevented the intended parallel edit phase. + +## Expected behavior + +Background subagents should leave `starting`, or fail with an actionable startup error. If concurrent startup is unsupported, `compose` should queue the calls visibly instead of leaving them indefinitely in `starting`. + +## Workaround + +Close the stuck sessions and launch each subagent in a foreground `compose` call. diff --git a/docs/issues/subagent-edits-not-applied-to-requested-worktree.md b/docs/issues/subagent-edits-not-applied-to-requested-worktree.md new file mode 100644 index 0000000..a2cff7c --- /dev/null +++ b/docs/issues/subagent-edits-not-applied-to-requested-worktree.md @@ -0,0 +1,13 @@ +# Subagent reported edits that were absent from the requested worktree + +## Summary + +A completed subagent reported that it edited `src/protocols/acp/v2.rs` in an explicitly named worktree and passed targeted checks. The parent session immediately inspected that worktree, but the file still matched `HEAD` and `git status` showed no modification for it. Re-prompting the same subagent to reapply the patch made the diff appear. + +## Impact + +The completion report and check results were not sufficient evidence that edits reached the requested worktree. The parent had to detect the missing diff and repeat the implementation step. + +## Expected behavior + +When a subagent is told to edit an absolute worktree path, its reported file changes and checks should apply to that path. If the harness uses an isolated fallback instead, the result should say so and return a patch or provide an explicit promotion step. diff --git a/docs/user/getting-started-and-configuration.md b/docs/user/getting-started-and-configuration.md index 1bb48af..82f43eb 100644 --- a/docs/user/getting-started-and-configuration.md +++ b/docs/user/getting-started-and-configuration.md @@ -126,7 +126,7 @@ kit prompt --root /path/to/project \ ### ACP and A2A server commands -Use `kit serve` for ACP on stdio plus a selectable HTTP protocol surface: +Use `kit serve` for ACP v1 on stdio plus selectable HTTP protocol surfaces: ```sh kit serve --root /path/to/project # A2A @@ -136,12 +136,13 @@ kit serve --root /path/to/project --http 127.0.0.1:7331 kit serve --remote-acp --no-a2a --no-stdio --http 0.0.0.0:8081 # daemon ``` -Without `--a2a` (or its `--http` alias), `serve` binds an available loopback port. Remote ACP is available at `/acp` over HTTP/SSE or WebSocket. Stdout remains reserved for ACP, so local stdio and remote ACP can run together. Add `--no-stdio` for a foreground daemon that does not depend on stdin; this option requires `--remote-acp`. SIGINT and, on Unix, SIGTERM stop accepts, interrupt active ACP sessions, and allow about five seconds for concurrent cleanup before remaining session actors are aborted. +Without `--a2a` (or its `--http` alias), `serve` binds an available loopback port. Remote ACP v1 and v2 negotiate on the standard `/acp` endpoint; `/acp/v2` is an explicit v2-only alias. Both routes use the same HTTP listener, bearer-token policy, and HTTP/SSE or WebSocket transports. The `kit serve` stdio connection remains ACP v1. Stdout remains reserved for ACP, so local stdio and remote ACP can run together. Add `--no-stdio` for a foreground daemon that does not depend on stdin; this option requires `--remote-acp`. SIGINT and, on Unix, SIGTERM stop accepts, interrupt active ACP sessions, and allow about five seconds for concurrent cleanup before remaining session actors are aborted. -Add `--server-credential-file /private/token` to require the file's single bearer token for every request on the HTTP listener. A non-loopback daemon must not be exposed without authentication and suitable network controls. Use `kit acp` when the host needs only ACP on stdio and no HTTP listener: +Add `--server-credential-file /private/token` to require the file's single bearer token for every request on the HTTP listener. A non-loopback daemon must not be exposed without authentication and suitable network controls. Use `kit acp` when the host needs only ACP on stdio and no HTTP listener. Select the wire version explicitly with `--protocol-version 1|2`; omitting it defaults to ACP v1: ```sh -kit acp --root /path/to/project +kit acp --root /path/to/project --protocol-version 1 +kit acp --root /path/to/project --protocol-version 2 ``` ## Configure `~/.kit/config.toml` diff --git a/docs/user/subagents-and-acp-harnesses.md b/docs/user/subagents-and-acp-harnesses.md index fdd5982..c7a0efd 100644 --- a/docs/user/subagents-and-acp-harnesses.md +++ b/docs/user/subagents-and-acp-harnesses.md @@ -64,7 +64,7 @@ Text-only turns omit `updates`. Capture is limited to 64 update objects and 64 K ## Choose the built-in `acp.kit` harness -`acp.kit` is always available and is the default when `[subagent].harness` is not configured. By default Kit launches the installed `kit` executable as `kit acp`. A built-in child inherits the runtime root, provider, model, MCP configuration and credential storage, cancellation, and nesting depth. An explicit `subagent.model` selection overrides the inherited model for that ACP session. It does not start an A2A listener. +`acp.kit` is always available and is the default when `[subagent].harness` is not configured. By default Kit launches the installed `kit` executable as `kit acp`, whose default stdio protocol is ACP v1. A built-in child inherits the runtime root, provider, model, MCP configuration and credential storage, cancellation, and nesting depth. An explicit `subagent.model` selection overrides the inherited model for that ACP session. It does not start an A2A listener. You can override only the executable and base arguments while preserving built-in Kit behavior: @@ -82,7 +82,7 @@ Built-in subagent transcripts are durable on disk, but their reusable parent-own ## Configure a generic ACP v1 harness -A generic harness must speak ACP v1 as newline-delimited JSON-RPC over stdio and support `initialize`, `session/new`, and `session/prompt`. `session/fork` and `session/close` are optional capabilities. Keep stdout protocol-only; the agent may log to stderr. Kit runs the executable directly with the runtime root as its current working directory and inherits the parent environment. It does not invoke a shell, so pipes, environment assignments, compound commands, and shell quoting in `command` or `args` do not work. +Generic external child harnesses remain ACP v1: they must speak newline-delimited JSON-RPC over stdio and support `initialize`, `session/new`, and `session/prompt`. `session/fork` and `session/close` are optional capabilities. Keep stdout protocol-only; the agent may log to stderr. Kit runs the executable directly with the runtime root as its current working directory and inherits the parent environment. It does not invoke a shell, so pipes, environment assignments, compound commands, and shell quoting in `command` or `args` do not work. Configure trusted argv profiles in `~/.kit/config.toml`: diff --git a/docs/user/tui-and-sessions.md b/docs/user/tui-and-sessions.md index 9df8e2c..e78eff5 100644 --- a/docs/user/tui-and-sessions.md +++ b/docs/user/tui-and-sessions.md @@ -1,6 +1,6 @@ # TUI Interaction, Sessions, and Recovery -Kit's terminal UI is an ACP client backed by a persisted session. It supports prompt editing, turn cancellation, transcript and tool-output navigation, fresh or resumed conversations, and automatic or manual context compaction. The session ID appears in the TUI header. Run `kit --help` and `kit --help` for the current, exhaustive command-line options. +Kit's bundled terminal UI is an ACP v2 client backed by a persisted session. It supports prompt editing, active-turn steering, turn cancellation, transcript and tool-output navigation, fresh or resumed conversations, and automatic or manual context compaction. The session ID appears in the TUI header. Run `kit --help` and `kit --help` for the current, exhaustive command-line options. ## Start or resume the terminal UI @@ -22,7 +22,7 @@ A session ID must be 1–128 ASCII letters, digits, `-`, or `_`. `kit prompt` us | Key or input | Action | | --- | --- | -| `Enter` | Send a non-empty prompt when idle | +| `Enter` | Send a non-empty prompt when idle; steer and finish the current response while active if the agent advertises that capability | | `Shift+Enter`, `Option+Enter`, `Ctrl+J` | Insert a newline | | `Esc` | Interrupt a running turn; dismiss a notice when idle | | `Command+B` | Move the newest running foreground top-level compose call to the background | @@ -46,6 +46,8 @@ A session ID must be 1–128 ASCII letters, digits, `-`, or `_`. `kit prompt` us Pasted text is inserted rather than sent. Bracketed paste is used when available; otherwise Kit treats a rapid key burst as a paste, so returns in that burst become line breaks. This keeps a multiline paste in one prompt. Press plain `Enter` afterward to submit it. +When the session is idle, `Enter` starts a normal prompt. While the agent is active, `Enter` uses ACP v2 `steer` injection with `finish` stream behavior only when the agent advertised both capabilities. An accepted injected user message appears in the transcript as part of the current turn. If steering is unavailable, the editor keeps the message and shows `this agent does not support active steering`. Local commands and agent-advertised session commands are available only while idle. + ### Attach local images and audio Drag one or more supported local media files into the terminal while editing a prompt. Terminals deliver a drop as pasted, shell-escaped paths rather than as a dedicated file-drop event. Kit treats the paste as attachments only when every parsed token resolves to a supported regular file. Mixed text and paths, unsupported files, invalid shell quoting, missing files, and ambiguous input remain ordinary pasted text. There is no `/attach` command. @@ -60,7 +62,7 @@ Assistant- and tool-produced media appears as portable Markdown placeholders or ### Interrupt a running turn or quit -Press `Esc` or `Ctrl+C` once to request cancellation. The TUI shows `interrupting the turn`, then records `turn interrupted` when cancellation completes. Sending another prompt while work is active is refused with `a turn is already running — esc interrupts it`. +Press `Esc` or `Ctrl+C` once to request cancellation. The TUI shows `interrupting the turn`, then records `turn interrupted` when cancellation completes. If a turn does not stop, press `Ctrl+C` again while Kit is cancelling to leave the TUI and terminate its agent child. On normal exit during a turn, Kit first requests cancellation and briefly allows the turn to unwind so tool outcomes can be persisted, then closes the session and releases its lock. @@ -68,13 +70,15 @@ Press `Command+B` to detach the newest running foreground top-level compose call At an idle, non-empty editor, `Ctrl+C` clears the prompt instead of unexpectedly discarding it and quitting in one step; press it again with the empty editor to quit. -## Start a new session and compact from the TUI +## Manage sessions and compact from the TUI -The TUI handles `/new`, `/model`, and `/effort` as exact local slash-command tokens. It also discovers agent commands through ACP and highlights them without interpreting them locally: +The TUI handles `/new`, `/resume`, `/close`, `/model`, and `/effort` as exact local slash-command tokens. It also discovers agent commands through ACP and highlights them without interpreting them locally: ```text /new /new Start by reviewing the tests +/resume +/close /compact /compact Continue with the migration /model @@ -82,7 +86,7 @@ The TUI handles `/new`, `/model`, and `/effort` as exact local slash-command tok /effort high ``` -`/new` closes the current session and starts a fresh persisted session. It clears the visible transcript but does not delete or alter the previous session, which remains resumable by its ID. Text following `/new` becomes the new session's first prompt. +These local commands are available only while the session is idle. `/new` closes the current session and starts a fresh persisted session. It clears the visible transcript but does not delete or alter the previous session, which remains resumable by its ID. Text following `/new` becomes the new session's first prompt. `/resume ` closes the current session, resumes the requested durable session, and replays its transcript. `/close` closes the current session and exits the TUI. `/model` opens the model selector. `/effort` opens the advertised ACP reasoning-effort selector; `/effort default|low|medium|high` selects directly. In either dialog, Tab toggles saving the selection to `~/.kit/config.toml`, Enter selects, and Esc closes. Saving `default` removes top-level `reasoning_effort`; other values update it without replacing unrelated TOML. A new or resumed process starts from the resolved CLI/TOML default unless the selection was saved. @@ -102,17 +106,19 @@ Fatal error records use their own versioned JSON schema and are not transcript c `HOME is unset; cannot locate durable sessions` means Kit cannot determine this directory. Set `HOME` to the intended home directory before starting Kit. -Transcript records are versioned and have consecutive generations. Normal items are appended and synced to disk before they are accepted into the in-memory conversation. Operations such as compaction append a replacement record; older records remain in the JSONL file, but readers treat the latest valid replacement as the canonical transcript. +Transcript records are versioned and have consecutive generations. Transcript schema v3 records the canonical workspace root so ACP discovery and resume cannot expose a session to another project; schema v1 and v2 records remain readable and gain that binding when they are next resumed. Normal items are appended and synced to disk before they are accepted into the in-memory conversation. Operations such as compaction append a replacement record; older records remain in the JSONL file, but readers treat the latest valid replacement as the canonical transcript. Older sessions under `/.kit/sessions` remain readable. The first resume validates and copies a legacy transcript into `~/.kit/sessions`; a live legacy lock produces `legacy session is actively locked by another Kit instance ...; stop it before resuming with this Kit version`. When both locations contain the ID, the global transcript is preferred. ### ACP session loading -ACP clients can restore a closed durable session with `session/load`. Kit advertises only the protocol's top-level `loadSession` capability; it does not advertise `session/resume` or `session/list`. Loading uses the exact requested session ID, resumes its canonical transcript, and returns the same model and reasoning configuration options as `session/new`. The requested workspace must match the Kit server's fixed root, and additional directories are not accepted. +ACP v1 clients restore a closed durable session with `session/load`. The v1 endpoint advertises only the protocol's top-level `loadSession` capability; it does not advertise `session/resume` or `session/list`. ACP v2 clients use `session/list` and `session/resume` instead. Both versions use the exact durable session ID and return the same model and reasoning configuration options as `session/new`. + +Session discovery and restoration are isolated to the server's canonical workspace root. A requested workspace must match that root, and additional directories are not accepted. Legacy transcripts under a project-local `.kit/sessions` directory follow the same migration and root checks as CLI resume; they do not make a same-named session visible from another workspace. -A load never applies the server process's configured `--force` setting. If another live Kit instance owns the session lock, loading fails instead of taking over the session. A missing or invalid ID also fails normally. After the session closes and releases its lock, an ACP client can load it again. +An arbitrary ACP load or resume never applies the server process's configured `--force` setting. The one exception is the initial resume requested by `kit tui --resume --force`: only that matching configured session may use the explicit stale-lock override. If another live Kit instance owns the session lock, restoration fails instead of taking over the session. A missing or invalid ID also fails normally. After the session closes and releases its lock, an ACP client can restore it again. -Before the load response, Kit replays the canonical transcript as ordered ACP updates for representable user text and attachments, assistant text and thoughts, and tool calls and results. Internal instructions, ambient context, notifications, and provider-specific content are not replayed to the client, but remain in the model transcript. Because compaction replaces the canonical transcript, loading a compacted session replays its canonical summary history rather than the superseded pre-compaction items. +Before the restoration response, Kit replays the canonical transcript as ordered ACP updates for representable user text and attachments, assistant text and thoughts, and tool calls and results. Internal instructions, ambient context, notifications, and provider-specific content are not replayed to the client, but remain in the model transcript. Because compaction replaces the canonical transcript, restoring a compacted session replays its canonical summary history rather than the superseded pre-compaction items. ### Session locks, `--resume`, and `--force` @@ -158,6 +164,6 @@ Set `KIT_THEME=light` or `KIT_THEME=dark` to override the detection — useful u ### TUI startup and terminal recovery -The TUI runs a `kit serve` child. If that child exits before opening the session—for example because the root is missing, credentials are unavailable, or an A2A address is already taken—the TUI reports the child's last diagnostics. A silent or wedged child eventually reports `the agent did not answer the ACP handshake within 30 seconds`. Fix that diagnostic and restart with the same `--resume` ID when a transcript was created. +The TUI runs a `kit serve` child with ACP v2 selected explicitly for its stdio connection; ordinary `kit serve` invocations continue to default to ACP v1 on stdio. If that child exits before opening the session—for example because the root is missing, credentials are unavailable, or an A2A address is already taken—the TUI reports the child's last diagnostics. A silent or wedged child eventually reports `the agent did not answer the ACP handshake within 30 seconds`. Fix that diagnostic and restart with the same `--resume` ID when a transcript was created. If an external hard kill leaves the shell in raw mode or mouse reporting appears as text, run `reset` (or reopen the terminal) before resuming. Prefer `Esc`, `Ctrl+C`, `Ctrl+D`, `SIGTERM`, or `SIGHUP` for normal shutdown so Kit can restore terminal modes, cancel active work, close the session, and clean up only locks proven stale. diff --git a/src/acp_child.rs b/src/acp_child.rs index 8190d84..71e5276 100644 --- a/src/acp_child.rs +++ b/src/acp_child.rs @@ -319,6 +319,8 @@ pub(crate) fn serve_command( let mut command = Command::new(std::env::current_exe()?); command .arg("serve") + .arg("--stdio-protocol-version") + .arg("2") .arg("--root") .arg(root) .arg("--model") @@ -1075,6 +1077,10 @@ mod tests { args.windows(2) .any(|pair| pair == ["--reasoning-effort", "medium"]) ); + assert!( + args.windows(2) + .any(|pair| pair == ["--stdio-protocol-version", "2"]) + ); assert!(args.iter().all(|arg| arg != "tui-secret")); assert!(command.as_std().get_envs().any(|(name, value)| { name == "OPENROUTER_API_KEY" && value == Some(std::ffi::OsStr::new("tui-secret")) diff --git a/src/main.rs b/src/main.rs index 62909e9..ab81b37 100644 --- a/src/main.rs +++ b/src/main.rs @@ -364,6 +364,14 @@ fn parse_otel_boolean(name: &str, value: &str) -> io::Result { } } +#[derive(Clone, Copy, Debug, ValueEnum)] +enum AcpProtocolVersion { + #[value(name = "1")] + V1, + #[value(name = "2")] + V2, +} + #[derive(Clone, Copy, Debug, ValueEnum)] enum AuthProvider { Openai, @@ -423,6 +431,9 @@ enum Command { /// Do not serve ACP on stdio. Requires remote ACP over HTTP. #[arg(long, requires = "remote_acp")] no_stdio: bool, + /// ACP wire version for stdio (defaults to v1 for compatibility). + #[arg(long, value_enum, default_value = "1", hide = true)] + stdio_protocol_version: AcpProtocolVersion, /// Require this file's bearer token on every HTTP request. #[arg(long, value_name = "PATH")] server_credential_file: Option, @@ -440,6 +451,9 @@ enum Command { }, /// Serve only the Agent Client Protocol on stdio. Acp { + /// ACP wire protocol version. + #[arg(long, value_enum, default_value = "1")] + protocol_version: AcpProtocolVersion, #[arg(long)] root: Option, #[arg(long)] @@ -645,15 +659,25 @@ async fn supervise_serve( runtime: std::sync::Arc, sessions: kit::protocols::acp::SessionRegistry, no_stdio: bool, + stdio_protocol_version: AcpProtocolVersion, http: kit::protocols::http::HttpServer, ) -> Result<(), Box> { - supervise_serve_with_trigger(runtime, sessions, no_stdio, http, termination_signal()).await + supervise_serve_with_trigger( + runtime, + sessions, + no_stdio, + stdio_protocol_version, + http, + termination_signal(), + ) + .await } async fn supervise_serve_with_trigger( runtime: std::sync::Arc, sessions: kit::protocols::acp::SessionRegistry, no_stdio: bool, + stdio_protocol_version: AcpProtocolVersion, mut http: kit::protocols::http::HttpServer, termination: impl Future>, ) -> Result<(), Box> { @@ -669,7 +693,14 @@ async fn supervise_serve_with_trigger( if no_stdio { std::future::pending::>().await } else { - kit::protocols::acp::serve_with_registry(runtime, stdio_sessions).await + match stdio_protocol_version { + AcpProtocolVersion::V1 => { + kit::protocols::acp::serve_with_registry(runtime, stdio_sessions).await + } + AcpProtocolVersion::V2 => { + kit::protocols::acp::v2::serve_with_registry(runtime, stdio_sessions).await + } + } } }; tokio::pin!(stdio); @@ -750,6 +781,7 @@ async fn main() -> Result<(), Box> { remote_acp, no_a2a, no_stdio, + stdio_protocol_version, server_credential_file, mcp, session_id, @@ -817,11 +849,13 @@ async fn main() -> Result<(), Box> { eprintln!("A2A listening on {bound}"); } if remote_acp { - eprintln!("ACP listening on http://{bound}/acp"); + eprintln!("ACP v1/v2 listening on http://{bound}/acp"); + eprintln!("ACP v2 listening on http://{bound}/acp/v2"); } - supervise_serve(runtime, sessions, no_stdio, http).await?; + supervise_serve(runtime, sessions, no_stdio, stdio_protocol_version, http).await?; } Command::Acp { + protocol_version, root, model, provider, @@ -876,7 +910,10 @@ async fn main() -> Result<(), Box> { credential_storage, ) .await?; - kit::protocols::acp::serve(runtime).await?; + match protocol_version { + AcpProtocolVersion::V1 => kit::protocols::acp::serve(runtime).await?, + AcpProtocolVersion::V2 => kit::protocols::acp::v2::serve(runtime).await?, + } } Command::Prompt { root, @@ -1490,6 +1527,8 @@ future_option = true Cli::try_parse_from(["kit", "acp", "--root", ".", "--provider", "speakeasy",]).is_ok() ); assert!(Cli::try_parse_from(["kit", "acp", "--provider", "unknown"]).is_err()); + assert!(Cli::try_parse_from(["kit", "acp", "--protocol-version", "2"]).is_ok()); + assert!(Cli::try_parse_from(["kit", "acp", "--protocol-version", "3"]).is_err()); for command in ["serve", "acp", "tui"] { assert!(Cli::try_parse_from(["kit", command, "--reasoning-effort", "high"]).is_ok()); } @@ -1501,6 +1540,8 @@ future_option = true assert!(Cli::try_parse_from(["kit", "serve", "--no-a2a"]).is_err()); assert!(Cli::try_parse_from(["kit", "serve", "--no-stdio"]).is_err()); assert!(Cli::try_parse_from(["kit", "serve", "--remote-acp"]).is_ok()); + assert!(Cli::try_parse_from(["kit", "serve", "--stdio-protocol-version", "2"]).is_ok()); + assert!(Cli::try_parse_from(["kit", "serve", "--stdio-protocol-version", "3"]).is_err()); assert!(Cli::try_parse_from(["kit", "serve", "--remote-acp", "--no-a2a"]).is_ok()); assert!( Cli::try_parse_from([ @@ -1540,11 +1581,18 @@ future_option = true tokio::time::timeout( Duration::from_secs(2), - supervise_serve_with_trigger(runtime, sessions, true, http, async move { - triggered - .await - .map_err(|_| io::Error::other("test shutdown trigger dropped")) - }), + supervise_serve_with_trigger( + runtime, + sessions, + true, + super::AcpProtocolVersion::V1, + http, + async move { + triggered + .await + .map_err(|_| io::Error::other("test shutdown trigger dropped")) + }, + ), ) .await .expect("supervisor shutdown timed out") diff --git a/src/protocols/acp.rs b/src/protocols/acp.rs index 32c3b2b..13cb573 100644 --- a/src/protocols/acp.rs +++ b/src/protocols/acp.rs @@ -1,6 +1,8 @@ use std::{ collections::HashMap, + future::Future, path::PathBuf, + pin::Pin, sync::{ Arc, Mutex, Weak, atomic::{AtomicU64, Ordering}, @@ -37,6 +39,8 @@ use tokio::{ time::timeout, }; +pub mod v2; + use crate::{ provider::{ModelGroup, ModelSelection, ReasoningEffort, SelectableAdapter, model_catalog}, runtime::{AcpDriverContext, BackgroundJobs, DetachRegistration, Runtime}, @@ -81,7 +85,7 @@ fn transcript_replay( replay } -fn user_replay_content(part: &Part) -> Option { +pub(super) fn user_replay_content(part: &Part) -> Option { let content = match part { Part::Text(text) => ContentBlock::Text(TextContent::new(text.text.clone())), Part::Media(media) => media_replay_content(media), @@ -142,7 +146,7 @@ fn tool_replay_update(part: &Part) -> Option { )) } -fn tool_output_raw(output: &ToolOutput) -> Option { +pub(super) fn tool_output_raw(output: &ToolOutput) -> Option { match output { ToolOutput::Text(text) => Some(json!({ "text": text })), ToolOutput::Structured(value) => Some(value.clone()), @@ -349,9 +353,22 @@ struct RegisteredSession { completed: watch::Receiver, } +type CloseV2Session = + Arc Pin + Send + 'static>> + Send + Sync + 'static>; + +#[derive(Clone)] +struct RegisteredV2Session { + token: u64, + interrupt: Arc, + close: CloseV2Session, + actor: AbortHandle, + completed: watch::Receiver, +} + struct RegistryState { accepting: bool, sessions: HashMap, + v2_sessions: HashMap, } struct SessionRegistryInner { @@ -373,12 +390,13 @@ impl SessionRegistry { state: Mutex::new(RegistryState { accepting: true, sessions: HashMap::new(), + v2_sessions: HashMap::new(), }), }), } } - fn next_token(&self) -> u64 { + pub(super) fn next_token(&self) -> u64 { self.inner.next_token.fetch_add(1, Ordering::Relaxed) } @@ -395,23 +413,56 @@ impl SessionRegistry { Ok(()) } - fn remove(&self, token: u64) { - self.inner + pub(super) fn register_v2( + &self, + token: u64, + interrupt: Arc, + close: CloseV2Session, + actor: AbortHandle, + completed: watch::Receiver, + ) -> Result<(), ()> { + let mut state = self + .inner .state .lock() - .expect("ACP session registry poisoned") - .sessions - .remove(&token); + .expect("ACP session registry poisoned"); + if !state.accepting { + return Err(()); + } + state.v2_sessions.insert( + token, + RegisteredV2Session { + token, + interrupt, + close, + actor, + completed, + }, + ); + Ok(()) + } + + pub(super) fn remove(&self, token: u64) { + let mut state = self + .inner + .state + .lock() + .expect("ACP session registry poisoned"); + state.sessions.remove(&token); + state.v2_sessions.remove(&token); } - fn close_gate_and_snapshot(&self) -> Vec { + fn close_gate_and_snapshot(&self) -> (Vec, Vec) { let mut state = self .inner .state .lock() .expect("ACP session registry poisoned"); state.accepting = false; - state.sessions.values().cloned().collect() + ( + state.sessions.values().cloned().collect(), + state.v2_sessions.values().cloned().collect(), + ) } pub async fn shutdown(&self) { @@ -419,10 +470,13 @@ impl SessionRegistry { } async fn shutdown_with_timeout(&self, limit: Duration) { - let sessions = self.close_gate_and_snapshot(); + let (sessions, v2_sessions) = self.close_gate_and_snapshot(); for session in &sessions { let _ = session.integration.interrupt_session(&session.session_id); } + for session in &v2_sessions { + (session.interrupt)(); + } let mut closing = JoinSet::new(); for mut session in sessions.iter().cloned() { @@ -441,6 +495,17 @@ impl SessionRegistry { }); } + for mut session in v2_sessions.iter().cloned() { + let registry = self.clone(); + closing.spawn(async move { + (session.close)().await; + if !*session.completed.borrow() { + let _ = session.completed.changed().await; + } + registry.remove(session.token); + }); + } + if timeout(limit, async { while closing.join_next().await.is_some() {} }) @@ -453,10 +518,18 @@ impl SessionRegistry { session.actor.abort(); } } + for session in &v2_sessions { + if !*session.completed.borrow() { + session.actor.abort(); + } + } while closing.join_next().await.is_some() {} for session in sessions { self.remove(session.token); } + for session in v2_sessions { + self.remove(session.token); + } } } } @@ -547,8 +620,8 @@ impl Server { } } - async fn initialize(&self, request: InitializeRequest) -> InitializeResponse { - InitializeResponse::new(request.protocol_version) + async fn initialize(&self, _request: InitializeRequest) -> InitializeResponse { + InitializeResponse::new(agent_client_protocol::schema::ProtocolVersion::V1) .agent_capabilities(capabilities()) .agent_info(agentkit_acp::Implementation::new( self.integration.name().to_string(), @@ -846,7 +919,7 @@ impl Server { } } -async fn detach_compose_call( +pub(super) async fn detach_compose_call( tasks: &TaskManagerHandle, background_jobs: &BackgroundJobs, call_id: &str, @@ -973,7 +1046,7 @@ async fn session_actor(actor: SessionActor) { } } -fn config_options( +pub(super) fn config_options( current: &ModelSelection, reasoning_effort: Option, catalog: &[ModelGroup], @@ -1027,7 +1100,7 @@ fn config_options( ] } -fn set_config( +pub(super) fn set_config( adapter: &SelectableAdapter, catalog: &[ModelGroup], request: SetSessionConfigOptionRequest, @@ -1072,7 +1145,7 @@ fn set_config( ))) } -async fn clean_up_session( +pub(super) async fn clean_up_session( session_id: &agentkit_acp::SessionId, driver: &mut LoopDriver, tasks: &TaskManagerHandle, @@ -1257,8 +1330,14 @@ async fn serve_transport( pub(crate) fn http_router(runtime: Arc, registry: SessionRegistry) -> axum::Router { agent_client_protocol_http::AcpHttpServer::new(move || { - component(Arc::clone(&runtime), registry.clone()) - .expect("Kit's fixed ACP integration must build") + let v1 = component(Arc::clone(&runtime), registry.clone()) + .expect("Kit's fixed ACP v1 integration must build"); + let v2 = v2::component(Arc::clone(&runtime), registry.clone()) + .expect("Kit's fixed ACP v2 integration must build"); + agent_client_protocol::Agent + .protocol_router() + .with_v1(v1) + .with_v2(v2) }) .with_options(agent_client_protocol_http::ServerOptions { health_endpoint: false, @@ -2605,9 +2684,10 @@ mod tests { .builder() .connect_with(client_transport, async move |connection| { let initialized = connection - .send_request(InitializeRequest::new(ProtocolVersion::V1)) + .send_request(InitializeRequest::new(ProtocolVersion::V2)) .block_task() .await?; + assert_eq!(initialized.protocol_version, ProtocolVersion::V1); assert!(initialized.agent_capabilities.load_session); let sessions = &initialized.agent_capabilities.session_capabilities; assert!(sessions.list.is_none()); diff --git a/src/protocols/acp/v2.rs b/src/protocols/acp/v2.rs new file mode 100644 index 0000000..4e3625e --- /dev/null +++ b/src/protocols/acp/v2.rs @@ -0,0 +1,2227 @@ +use std::{ + collections::HashMap, + path::PathBuf, + sync::{ + Arc, Mutex, Weak, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, +}; + +use agent_client_protocol::{Client, ConnectTo, Handled, Responder, V2ConnectionTo}; +use agentkit_acp::{ + AcpRuntimeError, + v2::{ + AcpInjectionBoundary, AcpIntegration, AcpSessionBinding, AcpSessionHandle, + AcpSessionUpdateSink, wire, + }, +}; +use agentkit_core::{ + CancellationController, FinishReason, Item, ItemKind, Part, SessionId, ToolOutput, +}; +use agentkit_loop::{LoopDriver, LoopError, LoopInterrupt, LoopStep, ModelSession}; +use agentkit_task_manager::{TaskEvent, TaskManagerHandle}; +use async_trait::async_trait; +use tokio::sync::{mpsc, oneshot, watch}; + +use crate::{ + provider::{SelectableAdapter, model_catalog}, + runtime::{AcpDriverContext, BackgroundJobs, Runtime}, +}; + +use super::{ + CancelBackgroundRequest, CancelBackgroundResponse, DetachComposeRequest, DetachComposeResponse, + SessionRegistry, +}; + +const PAGE_SIZE: usize = 100; +static NEXT_ERROR_MESSAGE_ID: AtomicU64 = AtomicU64::new(1); + +fn available_commands_update(session_id: wire::SessionId) -> wire::UpdateSessionNotification { + wire::UpdateSessionNotification::new( + session_id, + wire::SessionUpdate::AvailableCommandsUpdate(wire::AvailableCommandsUpdate::new(vec![ + wire::AvailableCommand::new("compact", "Compact the session context"), + ])), + ) +} + +fn complete_new_session( + response: wire::NewSessionResponse, + activation: oneshot::Sender<()>, + respond: impl FnOnce(wire::NewSessionResponse) -> Result<(), E>, + notify: impl FnOnce(wire::UpdateSessionNotification) -> Result<(), E>, +) -> Result<(), E> { + let session_id = response.session_id.clone(); + respond(response)?; + let _ = activation.send(()); + notify(available_commands_update(session_id)) +} + +fn sdk_error(error: AcpRuntimeError) -> agent_client_protocol::Error { + agent_client_protocol::util::internal_error(error.to_string()) +} + +fn map_loop_error(session_id: &wire::SessionId, error: &LoopError) -> AcpRuntimeError { + if matches!(error, LoopError::Cancelled) { + AcpRuntimeError::Cancelled + } else { + let session_id = agentkit_acp::SessionId::new(session_id.to_string()); + super::record_acp_loop_failure(&session_id, error) + } +} + +fn loop_error_stop_reason( + session_id: &wire::SessionId, + error: &LoopError, +) -> Result { + if matches!(error, LoopError::Cancelled) { + Ok(wire::StopReason::Cancelled) + } else { + Err(map_loop_error(session_id, error)) + } +} + +fn claim_prompt(busy: &AtomicBool) -> Result<(), AcpRuntimeError> { + busy.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .map(|_| ()) + .map_err(|_| AcpRuntimeError::Unsupported("session is already running a prompt".into())) +} + +#[derive(Clone)] +struct ConnectionSink(V2ConnectionTo); + +#[async_trait] +impl AcpSessionUpdateSink for ConnectionSink { + fn update(&self, notification: wire::UpdateSessionNotification) -> Result<(), AcpRuntimeError> { + self.0 + .send_notification(notification) + .map_err(|error| AcpRuntimeError::Sdk(error.to_string())) + } + + async fn update_acknowledged( + &self, + notification: wire::UpdateSessionNotification, + ) -> Result<(), AcpRuntimeError> { + self.update(notification) + } + + async fn flush(&self) -> Result<(), AcpRuntimeError> { + Ok(()) + } +} + +struct PromptCommand { + request: wire::PromptRequest, + cancellation_generation: u64, + reply: oneshot::Sender, AcpRuntimeError>>, +} + +enum Command { + Prompt(PromptCommand), + SetConfig { + request: wire::SetSessionConfigOptionRequest, + reply: oneshot::Sender>, + }, + Close { + reply: oneshot::Sender<()>, + }, +} + +struct SessionHandle { + token: u64, + commands: mpsc::Sender, + integration: AcpSessionHandle, + busy: Arc, + background_jobs: BackgroundJobs, + tasks: TaskManagerHandle, +} + +struct AttachedSession { + session_id: wire::SessionId, + config_options: Vec, + canonical_transcript: Vec, + activation: oneshot::Sender<()>, +} + +struct BindingGuard { + integration: Arc, + session_id: wire::SessionId, +} + +impl Drop for BindingGuard { + fn drop(&mut self) { + let _ = self.integration.unbind_session(&self.session_id); + } +} + +struct ActorGuard { + server: Weak, + registry: SessionRegistry, + session_id: wire::SessionId, + token: u64, + completed: watch::Sender, +} + +impl Drop for ActorGuard { + fn drop(&mut self) { + if let Some(server) = self.server.upgrade() { + server.remove_session(&self.session_id, self.token); + } + self.registry.remove(self.token); + self.completed.send_replace(true); + } +} + +struct Server { + runtime: Arc, + integration: Arc, + registry: SessionRegistry, + sessions: Mutex>, +} + +impl Server { + fn new(runtime: Arc, registry: SessionRegistry) -> Self { + Self { + runtime, + integration: Arc::new(AcpIntegration::default()), + registry, + sessions: Mutex::new(HashMap::new()), + } + } + + fn remove_session(&self, session_id: &wire::SessionId, token: u64) { + let mut sessions = self.sessions.lock().expect("ACP v2 session map poisoned"); + if sessions + .get(session_id) + .is_some_and(|session| session.token == token) + { + sessions.remove(session_id); + } + } + + fn initialize( + &self, + request: wire::InitializeRequest, + ) -> Result { + if request.protocol_version < wire::ProtocolVersion::V2 { + return Err(AcpRuntimeError::Unsupported( + "ACP v2 requires protocol version 2 or newer".into(), + )); + } + Ok(wire::InitializeResponse::new( + wire::ProtocolVersion::V2, + wire::Implementation::new("kit", env!("CARGO_PKG_VERSION")), + ) + .capabilities(agentkit_acp::v2::agent_capabilities())) + } + + async fn new_session( + self: &Arc, + request: wire::NewSessionRequest, + connection: V2ConnectionTo, + ) -> Result<(wire::NewSessionResponse, oneshot::Sender<()>), AcpRuntimeError> { + let claim = self.runtime.claim_session()?; + let attached = self + .attach_session( + request.cwd.0, + request + .additional_directories + .into_iter() + .map(|path| path.0) + .collect(), + connection, + claim, + ) + .await?; + let AttachedSession { + session_id, + config_options, + activation, + .. + } = attached; + Ok(( + wire::NewSessionResponse::new(session_id).config_options(config_options), + activation, + )) + } + + async fn resume_session( + self: &Arc, + request: wire::ResumeSessionRequest, + connection: V2ConnectionTo, + ) -> Result< + ( + wire::ResumeSessionResponse, + Vec, + oneshot::Sender<()>, + ), + AcpRuntimeError, + > { + let replay = match request.replay_from { + None => false, + Some(wire::ReplayFrom::Start(_)) => true, + Some(_) => { + return Err(AcpRuntimeError::Unsupported( + "unsupported ACP v2 replay cursor".into(), + )); + } + }; + let claim = self + .runtime + .claim_session_load(&request.session_id.to_string())?; + if !claim.is_configured() + && !crate::session::belongs_to_workspace( + self.runtime.root(), + &request.session_id.to_string(), + ) + .map_err(AcpRuntimeError::Loop)? + { + return Err(AcpRuntimeError::SessionNotFound( + request.session_id.to_string(), + )); + } + let attached = self + .attach_session( + request.cwd.0, + request + .additional_directories + .into_iter() + .map(|path| path.0) + .collect(), + connection, + claim, + ) + .await?; + let updates = if replay { + transcript_replay(&attached.session_id, &attached.canonical_transcript) + } else { + Vec::new() + }; + Ok(( + wire::ResumeSessionResponse::new().config_options(attached.config_options), + updates, + attached.activation, + )) + } + + fn list_sessions( + &self, + request: wire::ListSessionsRequest, + ) -> Result { + let cwd = self.runtime.root().to_path_buf(); + if request + .cwd + .as_ref() + .is_some_and(|requested| requested.0 != cwd) + { + return Ok(wire::ListSessionsResponse::new(Vec::new())); + } + let offset = request + .cursor + .as_ref() + .map(|cursor| parse_cursor(cursor.as_ref())) + .transpose()? + .unwrap_or(0); + let ids = crate::session::list_ids(self.runtime.root()).map_err(AcpRuntimeError::Loop)?; + if offset > ids.len() { + return Err(AcpRuntimeError::Unsupported( + "invalid session list cursor".into(), + )); + } + let end = ids.len().min(offset + PAGE_SIZE); + let sessions = ids[offset..end] + .iter() + .map(|id| wire::SessionInfo::new(wire::SessionId::new(id.as_str()), cwd.clone())) + .collect(); + let next = (end < ids.len()).then(|| wire::SessionListCursor::new(format!("offset:{end}"))); + Ok(wire::ListSessionsResponse::new(sessions).next_cursor(next)) + } + + async fn attach_session( + self: &Arc, + cwd: PathBuf, + additional_directories: Vec, + connection: V2ConnectionTo, + mut claim: crate::runtime::SessionClaim, + ) -> Result { + let session_id = wire::SessionId::new(claim.id()); + let cancellation = CancellationController::new(); + let sink = ConnectionSink(connection); + let binding = + AcpSessionBinding::new(session_id.clone(), SessionId::new(claim.id()), sink.clone()) + .cancellation(cancellation); + let handle = self.integration.bind_session(binding)?; + let binding = BindingGuard { + integration: Arc::clone(&self.integration), + session_id: session_id.clone(), + }; + let context = AcpDriverContext { + cwd, + additional_directories, + integration: Arc::clone(&self.integration), + cancellation: handle.cancellation_handle(), + }; + let driver = self.runtime.start_acp_driver(context, &mut claim).await?; + let current = driver.adapter.selection().map_err(AcpRuntimeError::Loop)?; + let reasoning = driver + .adapter + .reasoning_effort() + .map_err(AcpRuntimeError::Loop)?; + let catalog = model_catalog(¤t).await; + let config_options = v2_config_options(¤t, reasoning, &catalog); + let canonical_transcript = driver.canonical_transcript; + let background_jobs = driver.background_jobs.clone(); + let tasks = driver.tasks.clone(); + let mcp_events = self.runtime.subscribe_mcp(session_id.to_string()); + let (tx, rx) = mpsc::channel(8); + let busy = Arc::new(AtomicBool::new(false)); + let actor = SessionActor { + session_id: session_id.clone(), + integration: Arc::clone(&self.integration), + handle: handle.clone(), + busy: Arc::clone(&busy), + binding, + sink, + driver: driver.driver, + tasks: driver.tasks, + adapter: driver.adapter, + catalog, + commands: rx, + mcp_events, + }; + let token = self.registry.next_token(); + let (activation, activated) = oneshot::channel(); + let (completed, completion) = watch::channel(false); + let guard = ActorGuard { + server: Arc::downgrade(self), + registry: self.registry.clone(), + session_id: session_id.clone(), + token, + completed, + }; + let actor_task = tokio::spawn(async move { + let _guard = guard; + if activated.await.is_ok() { + session_actor(actor).await; + } + }); + let interrupt_handle = handle.clone(); + let interrupt = Arc::new(move || interrupt_handle.interrupt()); + let weak = tx.downgrade(); + let close = Arc::new(move || { + let weak = weak.clone(); + Box::pin(async move { + if let Some(commands) = weak.upgrade() { + let (reply, acknowledged) = oneshot::channel(); + if commands.send(Command::Close { reply }).await.is_ok() { + let _ = acknowledged.await; + } + } + }) as std::pin::Pin + Send>> + }); + self.registry + .register_v2( + token, + interrupt, + close, + actor_task.abort_handle(), + completion, + ) + .map_err(|()| AcpRuntimeError::ClientClosed)?; + drop(actor_task); + if let Err(error) = claim.commit() { + self.registry.remove(token); + return Err(error); + } + crate::events::emit(&crate::events::RuntimeEvent::SessionStarted { + session_id: session_id.to_string(), + }); + self.sessions + .lock() + .expect("ACP v2 session map poisoned") + .insert( + session_id.clone(), + SessionHandle { + token, + commands: tx, + integration: handle, + busy, + background_jobs, + tasks, + }, + ); + Ok(AttachedSession { + session_id, + config_options, + canonical_transcript, + activation, + }) + } + + async fn prepare_prompt( + &self, + request: wire::PromptRequest, + ) -> Result, AcpRuntimeError> { + let (sender, busy, handle, cancellation_generation) = + self.prompt_sender(&request.session_id)?; + let (reply, response) = oneshot::channel(); + if sender + .send(Command::Prompt(PromptCommand { + request, + cancellation_generation, + reply, + })) + .await + .is_err() + { + handle.stop_injection_turn(); + busy.store(false, Ordering::Release); + return Err(AcpRuntimeError::ClientClosed); + } + match response.await { + Ok(response) => response, + Err(_) => { + handle.stop_injection_turn(); + busy.store(false, Ordering::Release); + Err(AcpRuntimeError::ClientClosed) + } + } + } + + fn prompt_sender( + &self, + session_id: &wire::SessionId, + ) -> Result< + ( + mpsc::Sender, + Arc, + AcpSessionHandle, + u64, + ), + AcpRuntimeError, + > { + let sessions = self.sessions.lock().expect("ACP v2 session map poisoned"); + let session = sessions + .get(session_id) + .ok_or_else(|| AcpRuntimeError::SessionNotFound(session_id.to_string()))?; + claim_prompt(&session.busy)?; + let handle = session.integration.clone(); + handle.prepare_injection_turn(); + let generation = handle.cancellation_handle().generation(); + Ok(( + session.commands.clone(), + Arc::clone(&session.busy), + handle, + generation, + )) + } + + async fn set_config( + &self, + request: wire::SetSessionConfigOptionRequest, + ) -> Result { + let sender = self.sender(&request.session_id)?; + let (reply, response) = oneshot::channel(); + sender + .send(Command::SetConfig { request, reply }) + .await + .map_err(|_| AcpRuntimeError::ClientClosed)?; + response.await.map_err(|_| AcpRuntimeError::ClientClosed)? + } + + async fn cancel( + &self, + notification: wire::CancelSessionNotification, + ) -> Result<(), AcpRuntimeError> { + let handle = self + .sessions + .lock() + .expect("ACP v2 session map poisoned") + .get(¬ification.session_id) + .map(|session| session.integration.clone()); + if let Some(handle) = handle { + handle.interrupt(); + } + Ok(()) + } + + async fn close( + &self, + request: wire::CloseSessionRequest, + ) -> Result { + let session = self + .sessions + .lock() + .expect("ACP v2 session map poisoned") + .remove(&request.session_id) + .ok_or_else(|| AcpRuntimeError::SessionNotFound(request.session_id.to_string()))?; + session.integration.close(); + let (reply, acknowledged) = oneshot::channel(); + session + .commands + .send(Command::Close { reply }) + .await + .map_err(|_| AcpRuntimeError::ClientClosed)?; + acknowledged + .await + .map_err(|_| AcpRuntimeError::ClientClosed)?; + self.registry.remove(session.token); + Ok(wire::CloseSessionResponse::new()) + } + + fn sender( + &self, + session_id: &wire::SessionId, + ) -> Result, AcpRuntimeError> { + self.sessions + .lock() + .expect("ACP v2 session map poisoned") + .get(session_id) + .map(|session| session.commands.clone()) + .ok_or_else(|| AcpRuntimeError::SessionNotFound(session_id.to_string())) + } + + async fn detach_compose( + &self, + request: DetachComposeRequest, + ) -> Result { + let id = wire::SessionId::new(request.session_id.to_string()); + let (jobs, tasks) = self + .sessions + .lock() + .expect("ACP v2 session map poisoned") + .get(&id) + .map(|session| (session.background_jobs.clone(), session.tasks.clone())) + .ok_or_else(|| AcpRuntimeError::SessionNotFound(id.to_string()))?; + Ok(DetachComposeResponse { + detached: super::detach_compose_call(&tasks, &jobs, &request.call_id).await, + }) + } + + fn cancel_background( + &self, + request: CancelBackgroundRequest, + ) -> Result { + let id = wire::SessionId::new(request.session_id.to_string()); + let jobs = self + .sessions + .lock() + .expect("ACP v2 session map poisoned") + .get(&id) + .map(|session| session.background_jobs.clone()) + .ok_or_else(|| AcpRuntimeError::SessionNotFound(id.to_string()))?; + Ok(CancelBackgroundResponse { + cancelled: jobs.cancel(&request.call_id), + }) + } +} + +struct SessionActor { + session_id: wire::SessionId, + integration: Arc, + handle: AcpSessionHandle, + busy: Arc, + binding: BindingGuard, + sink: ConnectionSink, + driver: LoopDriver, + tasks: TaskManagerHandle, + adapter: SelectableAdapter, + catalog: Vec, + commands: mpsc::Receiver, + mcp_events: crate::tools::mcp::McpSubscription, +} + +async fn session_actor(actor: SessionActor) { + let SessionActor { + session_id, + integration, + handle, + busy, + binding, + sink, + mut driver, + tasks, + adapter, + catalog, + mut commands, + mut mcp_events, + } = actor; + let mut binding = Some(binding); + loop { + tokio::select! { + biased; + command = commands.recv() => match command { + Some(Command::Prompt(command)) => { + let result = prepare_prompt( + &session_id, + &integration, + &handle, + &mut driver, + command, + &sink, + ) + .await; + busy.store(false, Ordering::Release); + if let Err(error) = result { + eprintln!("ACP v2 prompt failed for {session_id}: {error}"); + } + } + Some(Command::SetConfig { request, reply }) => { + let result = set_v2_config(&adapter, &catalog, request); + let _ = reply.send(result); + } + Some(Command::Close { reply }) => { + let v1_id = agentkit_acp::SessionId::new(session_id.to_string()); + super::clean_up_session(&v1_id, &mut driver, &tasks).await; + drop(binding.take()); + let _ = reply.send(()); + break; + } + None => { + let v1_id = agentkit_acp::SessionId::new(session_id.to_string()); + super::clean_up_session(&v1_id, &mut driver, &tasks).await; + break; + } + }, + event = mcp_events.recv() => { + if let Some(event) = event { + let result = match driver.submit_input(vec![Item::notification(event.message)]) { + Ok(()) => drive_autonomous( + &session_id, + &integration, + &handle, + &busy, + &mut driver, + &sink, + ).await, + Err(error) => Err(map_loop_error(&session_id, &error)), + }; + if let Err(error) = result { + eprintln!("ACP v2 autonomous turn failed for {session_id}: {error}"); + } + } + } + event = tasks.next_event() => match event { + Some(TaskEvent::Completed(snapshot, _)) + if snapshot.kind == agentkit_task_manager::TaskKind::Background => + { + if let Err(error) = drive_autonomous( + &session_id, + &integration, + &handle, + &busy, + &mut driver, + &sink, + ).await { + eprintln!("ACP v2 autonomous turn failed for {session_id}: {error}"); + } + } + Some(_) => {} + None => break, + } + } + } +} + +async fn prepare_prompt( + session_id: &wire::SessionId, + integration: &AcpIntegration, + handle: &AcpSessionHandle, + driver: &mut LoopDriver, + command: PromptCommand, + sink: &impl AcpSessionUpdateSink, +) -> Result<(), AcpRuntimeError> { + let PromptCommand { + request, + cancellation_generation, + reply, + } = command; + let prepared = integration.prompt_to_items(&request).and_then(|items| { + driver + .submit_input(items) + .map_err(|error| map_loop_error(session_id, &error))?; + integration.begin_prompt(session_id) + }); + let user_message_id = match prepared { + Ok(message_id) => message_id, + Err(error) => { + handle.stop_injection_turn(); + let _ = reply.send(Err(error)); + return Ok(()); + } + }; + handle.start_injection_turn(); + let (start, started) = oneshot::channel(); + if reply.send(Ok(start)).is_err() || started.await.is_err() { + handle.stop_injection_turn(); + integration.finish_prompt(session_id); + return Ok(()); + } + let result = async { + sink.update(wire::UpdateSessionNotification::new( + session_id.clone(), + wire::SessionUpdate::UserMessage( + wire::UserMessage::new(user_message_id).content(request.prompt), + ), + ))?; + send_state( + sink, + session_id, + wire::StateUpdate::Running(wire::RunningStateUpdate::new()), + )?; + let stop_reason = match drive_prompt(session_id, driver, handle, cancellation_generation) + .await + { + Ok(stop_reason) => stop_reason, + Err(_) + if handle + .cancellation_handle() + .is_cancelled_since(cancellation_generation) => + { + wire::StopReason::Cancelled + } + Err(error) => { + terminalize_running_error(session_id, integration, handle, sink, &error).await?; + return Err(error); + } + }; + let _ = integration.flush_session_updates(session_id).await; + integration.finish_prompt(session_id); + send_state( + sink, + session_id, + wire::StateUpdate::Idle(wire::IdleStateUpdate::new().stop_reason(stop_reason)), + ) + } + .await; + integration.finish_prompt(session_id); + handle.stop_injection_turn(); + result +} + +#[async_trait] +trait TurnControl: Sync { + fn stop_injection_turn(&self); + fn is_cancelled_since(&self, generation: u64) -> bool; + async fn handle_injection_boundary( + &self, + driver: &mut LoopDriver, + terminal: bool, + ) -> Result; +} + +#[async_trait] +impl TurnControl for AcpSessionHandle { + fn stop_injection_turn(&self) { + AcpSessionHandle::stop_injection_turn(self); + } + + fn is_cancelled_since(&self, generation: u64) -> bool { + self.cancellation_handle().is_cancelled_since(generation) + } + + async fn handle_injection_boundary( + &self, + driver: &mut LoopDriver, + terminal: bool, + ) -> Result { + AcpSessionHandle::handle_injection_boundary(self, driver, terminal).await + } +} + +async fn drive_prompt( + session_id: &wire::SessionId, + driver: &mut LoopDriver, + control: &C, + cancellation_generation: u64, +) -> Result +where + S: ModelSession + Send + 'static, + C: TurnControl, +{ + loop { + let step = match driver.next().await { + Ok(step) => step, + Err(error) => { + control.stop_injection_turn(); + if control.is_cancelled_since(cancellation_generation) { + return Ok(wire::StopReason::Cancelled); + } + return loop_error_stop_reason(session_id, &error); + } + }; + if control.is_cancelled_since(cancellation_generation) { + return Ok(wire::StopReason::Cancelled); + } + match step { + LoopStep::Finished(result) => { + if result.finish_reason == FinishReason::ToolCall { + continue; + } + if result.finish_reason == FinishReason::Error { + control.stop_injection_turn(); + if control.is_cancelled_since(cancellation_generation) { + return Ok(wire::StopReason::Cancelled); + } + return Err(AcpRuntimeError::Loop("model turn failed".into())); + } + match control.handle_injection_boundary(driver, true).await { + Ok(AcpInjectionBoundary::Delivered | AcpInjectionBoundary::Continue) => { + continue; + } + Ok(AcpInjectionBoundary::Stopped) => { + return Ok(wire::StopReason::Cancelled); + } + Ok(AcpInjectionBoundary::Finished) => { + return Ok(finish_reason_to_stop_reason(&result.finish_reason)); + } + Err(error) => { + control.stop_injection_turn(); + if control.is_cancelled_since(cancellation_generation) { + return Ok(wire::StopReason::Cancelled); + } + return Err(error); + } + } + } + LoopStep::Interrupt(LoopInterrupt::AwaitingInput(_)) => { + match control.handle_injection_boundary(driver, true).await { + Ok(AcpInjectionBoundary::Delivered | AcpInjectionBoundary::Continue) => { + continue; + } + Ok(AcpInjectionBoundary::Stopped) => { + return Ok(wire::StopReason::Cancelled); + } + Ok(AcpInjectionBoundary::Finished) => return Ok(wire::StopReason::EndTurn), + Err(error) => { + control.stop_injection_turn(); + if control.is_cancelled_since(cancellation_generation) { + return Ok(wire::StopReason::Cancelled); + } + return Err(error); + } + } + } + LoopStep::Interrupt(LoopInterrupt::AfterToolResult(_)) => { + match control.handle_injection_boundary(driver, false).await { + Ok(AcpInjectionBoundary::Stopped) => { + return Ok(wire::StopReason::Cancelled); + } + Err(error) => { + control.stop_injection_turn(); + if control.is_cancelled_since(cancellation_generation) { + return Ok(wire::StopReason::Cancelled); + } + return Err(error); + } + _ => {} + } + } + LoopStep::Interrupt(LoopInterrupt::ApprovalRequest(_)) => { + if let Err(error) = driver.cancel_pending_approvals().await { + control.stop_injection_turn(); + if control.is_cancelled_since(cancellation_generation) { + return Ok(wire::StopReason::Cancelled); + } + return loop_error_stop_reason(session_id, &error); + } + } + } + } +} + +async fn drive_autonomous( + session_id: &wire::SessionId, + integration: &AcpIntegration, + handle: &AcpSessionHandle, + busy: &AtomicBool, + driver: &mut LoopDriver, + sink: &impl AcpSessionUpdateSink, +) -> Result<(), AcpRuntimeError> { + if claim_prompt(busy).is_err() { + return Ok(()); + } + handle.prepare_injection_turn(); + integration.finish_prompt(session_id); + let cancellation_generation = handle.cancellation_handle().generation(); + handle.start_injection_turn(); + let result = async { + send_state( + sink, + session_id, + wire::StateUpdate::Running(wire::RunningStateUpdate::new()), + )?; + let stop_reason = match drive_prompt(session_id, driver, handle, cancellation_generation) + .await + { + Ok(stop_reason) => stop_reason, + Err(_) + if handle + .cancellation_handle() + .is_cancelled_since(cancellation_generation) => + { + wire::StopReason::Cancelled + } + Err(error) => { + terminalize_running_error(session_id, integration, handle, sink, &error).await?; + return Err(error); + } + }; + let _ = integration.flush_session_updates(session_id).await; + send_state( + sink, + session_id, + wire::StateUpdate::Idle(wire::IdleStateUpdate::new().stop_reason(stop_reason)), + ) + } + .await; + integration.finish_prompt(session_id); + handle.stop_injection_turn(); + busy.store(false, Ordering::Release); + result +} + +async fn terminalize_running_error( + session_id: &wire::SessionId, + integration: &AcpIntegration, + handle: &AcpSessionHandle, + sink: &impl AcpSessionUpdateSink, + error: &AcpRuntimeError, +) -> Result<(), AcpRuntimeError> { + handle.stop_injection_turn(); + let _ = integration.flush_session_updates(session_id).await; + integration.finish_prompt(session_id); + + let [diagnostic, idle] = running_error_notifications(session_id, error); + let diagnostic_result = sink.update(diagnostic); + let idle_result = sink.update(idle); + diagnostic_result.and(idle_result) +} + +fn running_error_notifications( + session_id: &wire::SessionId, + error: &AcpRuntimeError, +) -> [wire::UpdateSessionNotification; 2] { + let sequence = NEXT_ERROR_MESSAGE_ID.fetch_add(1, Ordering::Relaxed); + [ + wire::UpdateSessionNotification::new( + session_id.clone(), + wire::SessionUpdate::AgentMessage( + wire::AgentMessage::new(wire::MessageId::new(format!( + "{session_id}-error-{sequence}" + ))) + .content(vec![wire::ContentBlock::Text(wire::TextContent::new( + error.to_string(), + ))]), + ), + ), + wire::UpdateSessionNotification::new( + session_id.clone(), + wire::SessionUpdate::StateUpdate(wire::StateUpdate::Idle( + wire::IdleStateUpdate::new().stop_reason(error_stop_reason()), + )), + ), + ] +} + +fn send_state( + sink: &impl AcpSessionUpdateSink, + session_id: &wire::SessionId, + state: wire::StateUpdate, +) -> Result<(), AcpRuntimeError> { + sink.update(wire::UpdateSessionNotification::new( + session_id.clone(), + wire::SessionUpdate::StateUpdate(state), + )) +} + +fn finish_reason_to_stop_reason(reason: &FinishReason) -> wire::StopReason { + match reason { + FinishReason::Completed => wire::StopReason::EndTurn, + FinishReason::ToolCall => wire::StopReason::EndTurn, + FinishReason::MaxTokens => wire::StopReason::MaxTokens, + FinishReason::Cancelled => wire::StopReason::Cancelled, + FinishReason::Blocked => wire::StopReason::Refusal, + FinishReason::Error => error_stop_reason(), + FinishReason::Other(reason) => wire::StopReason::Other(reason.clone()), + } +} + +fn error_stop_reason() -> wire::StopReason { + wire::StopReason::Other("_error".into()) +} + +fn v2_config_options( + current: &crate::provider::ModelSelection, + reasoning: Option, + catalog: &[crate::provider::ModelGroup], +) -> Vec { + let groups = catalog + .iter() + .map(|group| { + let name = match group.provider { + crate::ProviderKind::OpenAiSubscription => "OpenAI subscription", + crate::ProviderKind::OpenRouter => "OpenRouter", + crate::ProviderKind::Speakeasy => "Speakeasy", + }; + let options = group + .models + .iter() + .map(|model| { + let selection = crate::provider::ModelSelection { + provider: group.provider, + model: model.clone(), + }; + wire::SessionConfigSelectOption::new(selection.id(), model.clone()) + }) + .collect(); + wire::SessionConfigSelectGroup::new(group.provider.as_str(), name, options) + }) + .collect::>(); + let effort_options = [ + ("default", "Default"), + ("low", "Low"), + ("medium", "Medium"), + ("high", "High"), + ] + .into_iter() + .map(|(value, name)| wire::SessionConfigSelectOption::new(value, name)) + .collect(); + vec![ + wire::SessionConfigOption::select(super::MODEL_CONFIG_ID, "Model", current.id(), groups) + .category(wire::SessionConfigOptionCategory::Model), + wire::SessionConfigOption::select( + super::REASONING_EFFORT_CONFIG_ID, + "Reasoning effort", + reasoning.map_or("default", crate::provider::ReasoningEffort::as_str), + vec![wire::SessionConfigSelectGroup::new( + "reasoning-effort", + "Reasoning effort", + effort_options, + )], + ) + .category(wire::SessionConfigOptionCategory::ThoughtLevel), + ] +} + +fn set_v2_config( + adapter: &SelectableAdapter, + catalog: &[crate::provider::ModelGroup], + request: wire::SetSessionConfigOptionRequest, +) -> Result { + let config_id = request.config_id.to_string(); + let value = request + .value + .as_id() + .ok_or_else(|| AcpRuntimeError::Unsupported("selection requires an id value".into()))? + .to_string(); + match config_id.as_str() { + super::MODEL_CONFIG_ID => { + let selection = + crate::provider::ModelSelection::from_id(&value).map_err(AcpRuntimeError::Loop)?; + let offered = catalog.iter().any(|group| { + group.provider == selection.provider && group.models.contains(&selection.model) + }); + if !offered { + return Err(AcpRuntimeError::Unsupported( + "model is not in the advertised catalog".into(), + )); + } + adapter.select(selection).map_err(AcpRuntimeError::Loop)?; + } + super::REASONING_EFFORT_CONFIG_ID => { + let effort = + crate::provider::ReasoningEffort::from_id(&value).map_err(AcpRuntimeError::Loop)?; + adapter + .select_reasoning_effort(effort) + .map_err(AcpRuntimeError::Loop)?; + } + _ => { + return Err(AcpRuntimeError::Unsupported( + "unknown session configuration option".into(), + )); + } + } + let current = adapter.selection().map_err(AcpRuntimeError::Loop)?; + let reasoning = adapter.reasoning_effort().map_err(AcpRuntimeError::Loop)?; + Ok(wire::SetSessionConfigOptionResponse::new( + v2_config_options(¤t, reasoning, catalog), + )) +} + +fn parse_cursor(cursor: &str) -> Result { + cursor + .strip_prefix("offset:") + .and_then(|value| value.parse().ok()) + .ok_or_else(|| AcpRuntimeError::Unsupported("invalid session list cursor".into())) +} + +fn transcript_replay( + session_id: &wire::SessionId, + transcript: &[Item], +) -> Vec { + let mut replay = Vec::new(); + for (item_index, item) in transcript.iter().enumerate() { + let message_id = + |kind: &str| wire::MessageId::new(format!("{session_id}-replay-{item_index}-{kind}")); + match item.kind { + ItemKind::User => { + let content = item + .parts + .iter() + .filter_map(replay_content) + .collect::>(); + if !content.is_empty() { + replay.push(wire::SessionUpdate::UserMessage( + wire::UserMessage::new(message_id("user")).content(content), + )); + } + } + ItemKind::Assistant => { + let content = item + .parts + .iter() + .filter(|part| matches!(part, Part::Text(_))) + .filter_map(replay_content) + .collect::>(); + if !content.is_empty() { + replay.push(wire::SessionUpdate::AgentMessage( + wire::AgentMessage::new(message_id("agent")).content(content), + )); + } + let thought = item + .parts + .iter() + .filter_map(|part| match part { + Part::Reasoning(reasoning) => reasoning.summary.as_ref(), + _ => None, + }) + .map(|summary| wire::ContentBlock::Text(wire::TextContent::new(summary))) + .collect::>(); + if !thought.is_empty() { + replay.push(wire::SessionUpdate::AgentThought( + wire::AgentThought::new(message_id("thought")).content(thought), + )); + } + for part in &item.parts { + if let Part::ToolCall(call) = part { + replay.push(wire::SessionUpdate::ToolCallUpdate( + wire::ToolCallUpdate::new(wire::ToolCallId::new(call.id.to_string())) + .title(call.name.clone()) + .status(wire::ToolCallStatus::Pending) + .raw_input(call.input.clone()), + )); + } + } + } + ItemKind::Tool => { + for part in &item.parts { + if let Part::ToolResult(result) = part { + let status = if result.is_error { + wire::ToolCallStatus::Failed + } else { + wire::ToolCallStatus::Completed + }; + replay.push(wire::SessionUpdate::ToolCallUpdate( + wire::ToolCallUpdate::new(wire::ToolCallId::new( + result.call_id.to_string(), + )) + .status(status) + .raw_output(super::tool_output_raw(&result.output)) + .content(replay_tool_output_content(&result.output)), + )); + } + } + } + ItemKind::Developer if crate::compaction::is_compaction_summary(item) => { + let content = item + .parts + .iter() + .filter_map(replay_content) + .collect::>(); + if !content.is_empty() { + replay.push(wire::SessionUpdate::AgentMessage( + wire::AgentMessage::new(message_id("compaction")).content(content), + )); + } + } + ItemKind::System | ItemKind::Developer | ItemKind::Context | ItemKind::Notification => { + } + } + } + replay + .into_iter() + .map(|update| wire::UpdateSessionNotification::new(session_id.clone(), update)) + .collect() +} + +fn replay_content(part: &Part) -> Option { + let content = super::user_replay_content(part)?.content; + serde_json::from_value(serde_json::to_value(content).ok()?).ok() +} + +fn replay_tool_output_content(output: &ToolOutput) -> Option> { + let blocks = match output { + ToolOutput::Text(text) => vec![wire::ContentBlock::Text(wire::TextContent::new(text))], + ToolOutput::Structured(value) => vec![wire::ContentBlock::Text(wire::TextContent::new( + value.to_string(), + ))], + ToolOutput::Parts(parts) => parts.iter().filter_map(replay_content).collect(), + ToolOutput::Files(files) => files + .iter() + .map(|file| wire::ContentBlock::Text(wire::TextContent::new(format!("{file:?}")))) + .collect(), + }; + (!blocks.is_empty()).then(|| { + blocks + .into_iter() + .map(|block| wire::ToolCallContent::Content(Box::new(wire::Content::new(block)))) + .collect() + }) +} + +pub async fn serve(runtime: Arc) -> Result<(), AcpRuntimeError> { + serve_transport(runtime, agent_client_protocol::Stdio::new()).await +} + +pub async fn serve_with_registry( + runtime: Arc, + registry: SessionRegistry, +) -> Result<(), AcpRuntimeError> { + v2_router(runtime, registry)? + .connect_to(agent_client_protocol::Stdio::new()) + .await + .map_err(|error| AcpRuntimeError::Sdk(error.to_string())) +} + +async fn serve_transport( + runtime: Arc, + transport: impl ConnectTo + 'static, +) -> Result<(), AcpRuntimeError> { + let registry = SessionRegistry::new(); + let result = v2_router(runtime, registry.clone())? + .connect_to(transport) + .await + .map_err(|error| AcpRuntimeError::Sdk(error.to_string())); + registry.shutdown().await; + result +} + +pub(crate) fn http_router(runtime: Arc, registry: SessionRegistry) -> axum::Router { + agent_client_protocol_http::AcpHttpServer::new(move || { + v2_router(Arc::clone(&runtime), registry.clone()) + .expect("Kit's fixed ACP v2 integration must build") + }) + .with_options(agent_client_protocol_http::ServerOptions { + path: "/acp/v2".into(), + health_endpoint: false, + ..Default::default() + }) + .into_router() +} + +fn v2_router( + runtime: Arc, + registry: SessionRegistry, +) -> Result { + Ok(agent_client_protocol::Agent + .protocol_router() + .with_v2(component(runtime, registry)?)) +} + +pub(crate) fn component( + runtime: Arc, + registry: SessionRegistry, +) -> Result, AcpRuntimeError> { + let state = Arc::new(Server::new(runtime, registry)); + let agent = agent_client_protocol::Agent + .v2() + .name("kit") + .on_receive_request( + { + let state = Arc::clone(&state); + async move |request: wire::InitializeRequest, responder, _cx| { + responder.respond_with_result(state.initialize(request).map_err(sdk_error)) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let state = Arc::clone(&state); + async move |request: wire::NewSessionRequest, responder, cx| { + let state = Arc::clone(&state); + let connection = cx.clone(); + cx.spawn(async move { + match state.new_session(request, connection.clone()).await { + Ok((response, activation)) => complete_new_session( + response, + activation, + |response| responder.respond(response), + |notification| connection.send_notification(notification), + ), + Err(error) => responder.respond_with_error(sdk_error(error)), + } + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let state = Arc::clone(&state); + async move |request: wire::ListSessionsRequest, responder, _cx| { + responder.respond_with_result(state.list_sessions(request).map_err(sdk_error)) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let state = Arc::clone(&state); + async move |request: wire::ResumeSessionRequest, responder, cx| { + let state = Arc::clone(&state); + let connection = cx.clone(); + let session_id = request.session_id.clone(); + cx.spawn(async move { + match state.resume_session(request, connection.clone()).await { + Ok((response, replay, activation)) => { + for update in replay { + connection.send_notification(update)?; + } + responder.respond(response)?; + let _ = activation.send(()); + connection.send_notification(available_commands_update(session_id)) + } + Err(error) => responder.respond_with_error(sdk_error(error)), + } + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let state = Arc::clone(&state); + async move |request: wire::PromptRequest, responder, cx| { + let state = Arc::clone(&state); + cx.spawn(async move { + match state.prepare_prompt(request).await { + Ok(start) => { + responder.respond(wire::PromptResponse::new())?; + let _ = start.send(()); + Ok(()) + } + Err(error) => responder.respond_with_error(sdk_error(error)), + } + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let state = Arc::clone(&state); + async move |request: wire::SetSessionConfigOptionRequest, responder, cx| { + let state = Arc::clone(&state); + cx.spawn(async move { + responder + .respond_with_result(state.set_config(request).await.map_err(sdk_error)) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let integration = Arc::clone(&state.integration); + async move |request: wire::InjectSessionRequest, + responder: Responder, + cx| { + let integration = Arc::clone(&integration); + cx.spawn(async move { + integration.handle_inject_request(request, responder).await + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let integration = Arc::clone(&state.integration); + async move |request: wire::RevokeInjectSessionRequest, responder, _cx| { + responder.respond_with_result(integration.revoke_inject(request).await) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let state = Arc::clone(&state); + async move |request: DetachComposeRequest, responder, cx| { + let state = Arc::clone(&state); + cx.spawn(async move { + responder.respond_with_result( + state.detach_compose(request).await.map_err(sdk_error), + ) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let state = Arc::clone(&state); + async move |request: CancelBackgroundRequest, responder, _cx| { + responder + .respond_with_result(state.cancel_background(request).map_err(sdk_error)) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_notification( + { + let state = Arc::clone(&state); + async move |notification: wire::CancelSessionNotification, _cx| { + state.cancel(notification).await.map_err(sdk_error)?; + Ok(Handled::Yes) + } + }, + agent_client_protocol::on_receive_notification!(), + ) + .on_receive_request( + { + let state = Arc::clone(&state); + async move |request: wire::CloseSessionRequest, responder, cx| { + let state = Arc::clone(&state); + cx.spawn(async move { + responder.respond_with_result(state.close(request).await.map_err(sdk_error)) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ); + Ok(agent) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use agentkit_core::{MetadataMap, TurnCancellation}; + use agentkit_loop::{ + Agent, ModelAdapter, ModelTurn, ModelTurnEvent, ModelTurnResult, SessionConfig, TurnRequest, + }; + + use super::*; + + #[derive(Clone, Default)] + struct RecordingSink { + updates: Arc>>, + flushes: Arc, + } + + #[async_trait] + impl AcpSessionUpdateSink for RecordingSink { + fn update( + &self, + notification: wire::UpdateSessionNotification, + ) -> Result<(), AcpRuntimeError> { + self.updates.lock().unwrap().push(notification); + Ok(()) + } + + async fn update_acknowledged( + &self, + notification: wire::UpdateSessionNotification, + ) -> Result<(), AcpRuntimeError> { + self.update(notification) + } + + async fn flush(&self) -> Result<(), AcpRuntimeError> { + self.flushes.fetch_add(1, Ordering::Relaxed); + Ok(()) + } + } + + #[derive(Clone, Copy)] + enum TestOutcome { + FinishError, + ProviderError, + } + + struct TestAdapter { + outcome: TestOutcome, + turns: Arc, + interrupt: Option, + } + + struct TestSession { + outcome: TestOutcome, + turns: Arc, + interrupt: Option, + } + + struct TestTurn { + event: Option, + } + + #[async_trait] + impl ModelAdapter for TestAdapter { + type Session = TestSession; + + async fn start_session(&self, _config: SessionConfig) -> Result { + Ok(TestSession { + outcome: self.outcome, + turns: Arc::clone(&self.turns), + interrupt: self.interrupt.clone(), + }) + } + } + + #[async_trait] + impl ModelSession for TestSession { + type Turn = TestTurn; + + async fn begin_turn( + &mut self, + _request: TurnRequest, + _cancellation: Option, + ) -> Result { + self.turns.fetch_add(1, Ordering::Relaxed); + if let Some(handle) = &self.interrupt { + handle.interrupt(); + } + match self.outcome { + TestOutcome::FinishError => Ok(TestTurn { + event: Some(ModelTurnEvent::Finished(ModelTurnResult { + model: None, + response_id: None, + finish_reason: FinishReason::Error, + output_items: Vec::new(), + usage: None, + metadata: MetadataMap::new(), + })), + }), + TestOutcome::ProviderError => Err(LoopError::Provider("provider failed".into())), + } + } + } + + #[async_trait] + impl ModelTurn for TestTurn { + async fn next_event( + &mut self, + _cancellation: Option, + ) -> Result, LoopError> { + Ok(self.event.take()) + } + } + + struct TestTurnControl { + pending_steer: AtomicBool, + boundaries: AtomicU64, + stops: AtomicU64, + } + + impl TestTurnControl { + fn new(pending_steer: bool) -> Self { + Self { + pending_steer: AtomicBool::new(pending_steer), + boundaries: AtomicU64::new(0), + stops: AtomicU64::new(0), + } + } + } + + #[async_trait] + impl TurnControl for TestTurnControl { + fn stop_injection_turn(&self) { + self.stops.fetch_add(1, Ordering::Relaxed); + } + + fn is_cancelled_since(&self, _generation: u64) -> bool { + false + } + + async fn handle_injection_boundary( + &self, + _driver: &mut LoopDriver, + _terminal: bool, + ) -> Result { + self.boundaries.fetch_add(1, Ordering::Relaxed); + if self.pending_steer.swap(false, Ordering::Relaxed) { + Ok(AcpInjectionBoundary::Delivered) + } else { + Ok(AcpInjectionBoundary::Finished) + } + } + } + + async fn test_driver( + outcome: TestOutcome, + session_id: &str, + ) -> (LoopDriver, Arc) { + test_driver_with_interrupt(outcome, session_id, None).await + } + + async fn test_driver_with_interrupt( + outcome: TestOutcome, + session_id: &str, + interrupt: Option, + ) -> (LoopDriver, Arc) { + let turns = Arc::new(AtomicU64::new(0)); + let driver = Agent::builder() + .model(TestAdapter { + outcome, + turns: Arc::clone(&turns), + interrupt, + }) + .build() + .unwrap() + .start(SessionConfig::new(SessionId::new(session_id)).without_cache()) + .await + .unwrap(); + (driver, turns) + } + + #[test] + fn new_session_response_is_enqueued_before_activation_and_notifications() { + let (activation, activated) = oneshot::channel(); + let activated = std::cell::RefCell::new(activated); + let events = std::cell::RefCell::new(Vec::new()); + let response = wire::NewSessionResponse::new(wire::SessionId::new("session")); + + complete_new_session( + response, + activation, + |_| { + assert!(matches!( + activated.borrow_mut().try_recv(), + Err(oneshot::error::TryRecvError::Empty) + )); + events.borrow_mut().push("response"); + Ok::<(), ()>(()) + }, + |_| { + assert_eq!(activated.borrow_mut().try_recv(), Ok(())); + events.borrow_mut().push("notification"); + Ok::<(), ()>(()) + }, + ) + .unwrap(); + + assert_eq!(events.into_inner(), ["response", "notification"]); + } + + #[test] + fn loop_failures_are_fatal_while_cancellation_stays_cancelled() { + let session_id = wire::SessionId::new("loop-error-test"); + let error = LoopError::InvalidState("broken".into()); + + let Err(AcpRuntimeError::Loop(rendered)) = loop_error_stop_reason(&session_id, &error) + else { + panic!("non-cancellation loop errors must fail the turn"); + }; + assert!(rendered.starts_with("invalid driver state: broken")); + assert!(rendered.contains("fatal log:")); + assert!(matches!( + map_loop_error(&session_id, &LoopError::Cancelled), + AcpRuntimeError::Cancelled + )); + assert_eq!( + loop_error_stop_reason(&session_id, &LoopError::Cancelled).unwrap(), + wire::StopReason::Cancelled + ); + } + + #[tokio::test] + async fn foreground_provider_error_after_running_terminalizes_once() { + let integration = AcpIntegration::default(); + let sink = RecordingSink::default(); + let session_id = wire::SessionId::new("foreground-provider-error"); + let handle = integration + .bind_session(AcpSessionBinding::new( + session_id.clone(), + SessionId::new("foreground-provider-error-loop"), + sink.clone(), + )) + .unwrap(); + handle.prepare_injection_turn(); + let cancellation_generation = handle.cancellation_handle().generation(); + let (mut driver, turns) = + test_driver(TestOutcome::ProviderError, "foreground-provider-error-loop").await; + let (reply, response) = oneshot::channel(); + let command = PromptCommand { + request: wire::PromptRequest::new( + session_id.clone(), + vec![wire::ContentBlock::Text(wire::TextContent::new("fail"))], + ), + cancellation_generation, + reply, + }; + let acknowledge = async move { + response.await.unwrap().unwrap().send(()).unwrap(); + }; + + let (result, ()) = tokio::join!( + prepare_prompt( + &session_id, + &integration, + &handle, + &mut driver, + command, + &sink, + ), + acknowledge, + ); + + assert!(matches!(result, Err(AcpRuntimeError::Loop(_)))); + assert_eq!(turns.load(Ordering::Relaxed), 1); + assert_eq!(sink.flushes.load(Ordering::Relaxed), 1); + let updates = sink.updates.lock().unwrap(); + assert_eq!(updates.len(), 4); + assert!(matches!( + updates[1].update, + wire::SessionUpdate::StateUpdate(wire::StateUpdate::Running(_)) + )); + assert!( + serde_json::to_string(&updates[2].update) + .unwrap() + .contains("provider failed") + ); + assert!(matches!( + updates[2].update, + wire::SessionUpdate::AgentMessage(_) + )); + assert_eq!( + updates + .iter() + .filter(|update| matches!( + update.update, + wire::SessionUpdate::StateUpdate(wire::StateUpdate::Idle(_)) + )) + .count(), + 1 + ); + assert!(matches!( + updates.last().map(|update| &update.update), + Some(wire::SessionUpdate::StateUpdate(wire::StateUpdate::Idle(idle))) + if idle.stop_reason == Some(error_stop_reason()) + )); + } + + #[tokio::test] + async fn finish_error_stops_before_delivering_pending_steer() { + let (mut driver, turns) = test_driver(TestOutcome::FinishError, "finish-error").await; + driver + .submit_input(vec![Item::text(ItemKind::User, "fail")]) + .unwrap(); + let control = TestTurnControl::new(true); + + let result = drive_prompt( + &wire::SessionId::new("finish-error"), + &mut driver, + &control, + 0, + ) + .await; + + assert!(matches!( + result, + Err(AcpRuntimeError::Loop(message)) if message == "model turn failed" + )); + assert_eq!(turns.load(Ordering::Relaxed), 1); + assert_eq!(control.boundaries.load(Ordering::Relaxed), 0); + assert!(control.pending_steer.load(Ordering::Relaxed)); + assert_eq!(control.stops.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn cancellation_race_wins_over_provider_error() { + let integration = AcpIntegration::default(); + let sink = RecordingSink::default(); + let session_id = wire::SessionId::new("cancel-race"); + let handle = integration + .bind_session(AcpSessionBinding::new( + session_id.clone(), + SessionId::new("cancel-race-loop"), + sink, + )) + .unwrap(); + handle.prepare_injection_turn(); + handle.start_injection_turn(); + let generation = handle.cancellation_handle().generation(); + handle.interrupt(); + assert!(handle.cancellation_handle().is_cancelled_since(generation)); + let (mut driver, _) = test_driver(TestOutcome::ProviderError, "cancel-race-loop").await; + driver + .submit_input(vec![Item::text(ItemKind::User, "cancel")]) + .unwrap(); + + let result = drive_prompt(&session_id, &mut driver, &handle, generation).await; + + assert_eq!(result.unwrap(), wire::StopReason::Cancelled); + } + + #[tokio::test] + async fn provider_error_without_cancellation_remains_an_error() { + let (mut driver, _) = test_driver(TestOutcome::ProviderError, "provider-error").await; + driver + .submit_input(vec![Item::text(ItemKind::User, "fail")]) + .unwrap(); + let control = TestTurnControl::new(false); + + let result = drive_prompt( + &wire::SessionId::new("provider-error"), + &mut driver, + &control, + 0, + ) + .await; + + assert!(matches!(result, Err(AcpRuntimeError::Loop(_)))); + assert_eq!(control.stops.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn autonomous_provider_error_emits_diagnostic_and_one_error_idle() { + let integration = AcpIntegration::default(); + let sink = RecordingSink::default(); + let session_id = wire::SessionId::new("autonomous-provider-error"); + let handle = integration + .bind_session(AcpSessionBinding::new( + session_id.clone(), + SessionId::new("autonomous-provider-error-loop"), + sink.clone(), + )) + .unwrap(); + let (mut driver, turns) = + test_driver(TestOutcome::ProviderError, "autonomous-provider-error-loop").await; + driver + .submit_input(vec![Item::notification("background event")]) + .unwrap(); + let busy = AtomicBool::new(false); + + let result = drive_autonomous( + &session_id, + &integration, + &handle, + &busy, + &mut driver, + &sink, + ) + .await; + + assert!(matches!(result, Err(AcpRuntimeError::Loop(_)))); + assert_eq!(turns.load(Ordering::Relaxed), 1); + assert_eq!(sink.flushes.load(Ordering::Relaxed), 1); + let updates = sink.updates.lock().unwrap(); + assert_eq!(updates.len(), 3); + assert!(matches!( + updates[0].update, + wire::SessionUpdate::StateUpdate(wire::StateUpdate::Running(_)) + )); + assert!( + serde_json::to_string(&updates[1].update) + .unwrap() + .contains("provider failed") + ); + assert!(matches!( + updates[1].update, + wire::SessionUpdate::AgentMessage(_) + )); + assert!(matches!( + &updates[2].update, + wire::SessionUpdate::StateUpdate(wire::StateUpdate::Idle(idle)) + if idle.stop_reason == Some(error_stop_reason()) + )); + } + + #[tokio::test] + async fn autonomous_cancellation_has_no_error_diagnostic_or_continuation() { + let integration = AcpIntegration::default(); + let sink = RecordingSink::default(); + let session_id = wire::SessionId::new("autonomous-cancel"); + let handle = integration + .bind_session(AcpSessionBinding::new( + session_id.clone(), + SessionId::new("autonomous-cancel-loop"), + sink.clone(), + )) + .unwrap(); + let (mut driver, turns) = test_driver_with_interrupt( + TestOutcome::ProviderError, + "autonomous-cancel-loop", + Some(handle.clone()), + ) + .await; + driver + .submit_input(vec![Item::notification("background event")]) + .unwrap(); + let busy = AtomicBool::new(false); + + drive_autonomous( + &session_id, + &integration, + &handle, + &busy, + &mut driver, + &sink, + ) + .await + .unwrap(); + + assert_eq!(turns.load(Ordering::Relaxed), 1); + assert_eq!(sink.flushes.load(Ordering::Relaxed), 1); + let updates = sink.updates.lock().unwrap(); + assert_eq!(updates.len(), 2); + assert!(matches!( + updates[0].update, + wire::SessionUpdate::StateUpdate(wire::StateUpdate::Running(_)) + )); + assert!( + !updates + .iter() + .any(|update| matches!(update.update, wire::SessionUpdate::AgentMessage(_))) + ); + assert!(matches!( + &updates[1].update, + wire::SessionUpdate::StateUpdate(wire::StateUpdate::Idle(idle)) + if idle.stop_reason == Some(wire::StopReason::Cancelled) + )); + } + + #[tokio::test] + async fn autonomous_finish_error_emits_running_diagnostic_and_one_error_idle() { + let integration = AcpIntegration::default(); + let sink = RecordingSink::default(); + let session_id = wire::SessionId::new("autonomous-error"); + let handle = integration + .bind_session(AcpSessionBinding::new( + session_id.clone(), + SessionId::new("autonomous-error-loop"), + sink.clone(), + )) + .unwrap(); + let (mut driver, turns) = + test_driver(TestOutcome::FinishError, "autonomous-error-loop").await; + driver + .submit_input(vec![Item::notification("background event")]) + .unwrap(); + let busy = AtomicBool::new(false); + + let result = drive_autonomous( + &session_id, + &integration, + &handle, + &busy, + &mut driver, + &sink, + ) + .await; + + assert!(matches!(result, Err(AcpRuntimeError::Loop(_)))); + assert_eq!(turns.load(Ordering::Relaxed), 1); + assert!(!busy.load(Ordering::Relaxed)); + assert_eq!(sink.flushes.load(Ordering::Relaxed), 1); + let updates = sink.updates.lock().unwrap(); + assert!(matches!( + updates[0].update, + wire::SessionUpdate::StateUpdate(wire::StateUpdate::Running(_)) + )); + assert!(matches!( + updates[1].update, + wire::SessionUpdate::AgentMessage(_) + )); + assert!( + serde_json::to_string(&updates[1].update) + .unwrap() + .contains("loop error: model turn failed") + ); + assert_eq!( + updates + .iter() + .filter(|update| matches!( + update.update, + wire::SessionUpdate::StateUpdate(wire::StateUpdate::Idle(_)) + )) + .count(), + 1 + ); + assert!(matches!( + updates.last().map(|update| &update.update), + Some(wire::SessionUpdate::StateUpdate(wire::StateUpdate::Idle(idle))) + if idle.stop_reason == Some(error_stop_reason()) + )); + } + + #[test] + fn available_commands_advertises_only_compact() { + let notification = available_commands_update(wire::SessionId::new("session")); + let wire::SessionUpdate::AvailableCommandsUpdate(update) = notification.update else { + panic!("expected available commands update"); + }; + assert_eq!( + update + .available_commands + .iter() + .map(|command| command.name.as_str()) + .collect::>(), + ["compact"] + ); + } + + #[test] + fn concurrent_prompt_admission_is_rejected_until_the_turn_finishes() { + let busy = AtomicBool::new(false); + + claim_prompt(&busy).unwrap(); + assert!(matches!( + claim_prompt(&busy), + Err(AcpRuntimeError::Unsupported(_)) + )); + + busy.store(false, Ordering::Release); + claim_prompt(&busy).unwrap(); + } + + #[test] + fn initialize_negotiates_v2_and_advertises_injection() { + let root = tempfile::tempdir().unwrap(); + let runtime = Runtime::new(root.path(), "gpt-5.4").unwrap(); + let server = Server::new(runtime, SessionRegistry::new()); + let response = server + .initialize(wire::InitializeRequest::new( + wire::ProtocolVersion::V2, + wire::Implementation::new("test-client", "0"), + )) + .unwrap(); + + assert_eq!(response.protocol_version, wire::ProtocolVersion::V2); + let mut newer = wire::InitializeRequest::new( + wire::ProtocolVersion::V2, + wire::Implementation::new("newer-client", "0"), + ); + newer.protocol_version = serde_json::from_value(json!(99)).unwrap(); + assert_eq!( + server.initialize(newer).unwrap().protocol_version, + wire::ProtocolVersion::V2 + ); + let session = response.capabilities.session.expect("session capabilities"); + assert!(session.inject.is_some()); + assert!(session.delete.is_none()); + assert!(session.fork.is_none()); + assert!( + server + .initialize(wire::InitializeRequest::new( + wire::ProtocolVersion::V1, + wire::Implementation::new("test-client", "0"), + )) + .is_err() + ); + } + + #[test] + fn replay_uses_complete_v2_messages_with_stable_ids() { + let session_id = wire::SessionId::new("saved"); + let transcript = [ + Item::text(ItemKind::User, "question"), + Item::text(ItemKind::Assistant, "answer"), + ]; + + let replay = transcript_replay(&session_id, &transcript); + let replay_again = transcript_replay(&session_id, &transcript); + + assert_eq!( + serde_json::to_value(&replay).unwrap(), + serde_json::to_value(&replay_again).unwrap() + ); + assert_eq!(replay.len(), 2); + assert!(matches!( + replay[0].update, + wire::SessionUpdate::UserMessage(_) + )); + assert!(matches!( + replay[1].update, + wire::SessionUpdate::AgentMessage(_) + )); + } + + #[test] + fn v2_config_mapping_uses_v2_ids_categories_and_values() { + let current = + crate::provider::ModelSelection::new(crate::ProviderKind::OpenRouter, "test-model"); + let catalog = [crate::provider::ModelGroup { + provider: crate::ProviderKind::OpenRouter, + models: vec!["test-model".into(), "other-model".into()], + }]; + + let options = v2_config_options( + ¤t, + Some(crate::provider::ReasoningEffort::High), + &catalog, + ); + let encoded = serde_json::to_value(&options).unwrap(); + + assert_eq!(encoded[0]["configId"], "model"); + assert_eq!(encoded[0]["category"], "model"); + assert_eq!(encoded[0]["currentValue"], "openrouter:test-model"); + assert!(encoded[0].get("id").is_none()); + assert_eq!(encoded[1]["configId"], "reasoning_effort"); + assert_eq!(encoded[1]["category"], "thought_level"); + assert_eq!(encoded[1]["currentValue"], "high"); + } + + #[test] + fn all_finish_reasons_map_to_faithful_v2_idle_reasons() { + assert_eq!( + finish_reason_to_stop_reason(&FinishReason::Completed), + wire::StopReason::EndTurn + ); + assert_eq!( + finish_reason_to_stop_reason(&FinishReason::MaxTokens), + wire::StopReason::MaxTokens + ); + assert_eq!( + finish_reason_to_stop_reason(&FinishReason::Cancelled), + wire::StopReason::Cancelled + ); + assert_eq!( + finish_reason_to_stop_reason(&FinishReason::Blocked), + wire::StopReason::Refusal + ); + assert_eq!( + finish_reason_to_stop_reason(&FinishReason::Error), + wire::StopReason::Other("_error".into()) + ); + assert_eq!( + finish_reason_to_stop_reason(&FinishReason::Other("provider-stop".into())), + wire::StopReason::Other("provider-stop".into()) + ); + } + + #[test] + fn replay_tool_results_include_visible_content() { + let content = replay_tool_output_content(&ToolOutput::text("done")).unwrap(); + assert!(matches!( + content.as_slice(), + [wire::ToolCallContent::Content(content)] + if matches!(&content.content, wire::ContentBlock::Text(text) if text.text == "done") + )); + } + + #[test] + fn cursors_are_stable_and_reject_malformed_values() { + assert_eq!(parse_cursor("offset:100").unwrap(), 100); + assert!(parse_cursor("100").is_err()); + assert!(parse_cursor("offset:nope").is_err()); + } +} diff --git a/src/protocols/http.rs b/src/protocols/http.rs index f9f453b..14981dd 100644 --- a/src/protocols/http.rs +++ b/src/protocols/http.rs @@ -147,7 +147,10 @@ pub async fn start_with_registry( .then(|| crate::protocols::a2a::dispatcher(runtime.clone(), bound, credential.is_some())) .transpose()? .map(Arc::new); - let acp = serve_remote_acp.then(|| crate::protocols::acp::http_router(runtime, sessions)); + let acp = serve_remote_acp.then(|| { + crate::protocols::acp::http_router(runtime.clone(), sessions.clone()) + .merge(crate::protocols::acp::v2::http_router(runtime, sessions)) + }); let stop_accepting = CancellationToken::new(); let accepts_stopped = CancellationToken::new(); let shutdown_connections = CancellationToken::new(); @@ -280,7 +283,7 @@ async fn dispatch( .expect("fixed unauthorized response")); } - if request.uri().path() == "/acp" + if matches!(request.uri().path(), "/acp" | "/acp/v2") && let Some(router) = acp { let response = router @@ -365,6 +368,15 @@ mod tests { unauthorized_acp.headers()[reqwest::header::WWW_AUTHENTICATE], "Bearer" ); + assert_eq!( + client + .post(format!("http://{bound}/acp/v2")) + .send() + .await + .unwrap() + .status(), + reqwest::StatusCode::UNAUTHORIZED + ); let card = format!("http://{bound}/.well-known/agent-card.json"); assert_eq!( @@ -389,6 +401,29 @@ mod tests { "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": { + "protocolVersion": 2, + "info": { "name": "kit-test", "version": "0" } + } + })) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::OK); + let routed: serde_json::Value = response.json().await.unwrap(); + assert_eq!( + routed["result"]["protocolVersion"], 2, + "dual-protocol endpoint did not route to ACP v2: {routed}" + ); + + let response = client + .post(format!("http://{bound}/acp")) + .bearer_auth("secret-token") + .header(reqwest::header::ACCEPT, "application/json") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "initialize", "params": { "protocolVersion": 1 } })) .send() @@ -396,6 +431,31 @@ mod tests { .unwrap(); assert_eq!(response.status(), reqwest::StatusCode::OK); assert!(response.headers().contains_key("acp-connection-id")); + let initialized: serde_json::Value = response.json().await.unwrap(); + assert_eq!(initialized["result"]["protocolVersion"], 1); + + let response = client + .post(format!("http://{bound}/acp/v2")) + .bearer_auth("secret-token") + .header(reqwest::header::ACCEPT, "application/json") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "initialize", + "params": { + "protocolVersion": 99, + "info": { "name": "kit-test", "version": "0" } + } + })) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::OK); + let initialized: serde_json::Value = response.json().await.unwrap(); + assert_eq!( + initialized["result"]["protocolVersion"], 2, + "unexpected ACP v2 initialize response: {initialized}" + ); let mut websocket = tokio::net::TcpStream::connect(bound).await.unwrap(); websocket diff --git a/src/runtime.rs b/src/runtime.rs index 0d086a9..f3da7f5 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -14,7 +14,9 @@ use agentkit_context::{AgentsMd, ContextLoader}; use agentkit_core::{ CancellationController, CancellationHandle, FinishReason, Item, ItemKind, Part, }; -use agentkit_loop::{Agent, LoopDriver, LoopError, LoopInterrupt, LoopStep, SessionConfig}; +use agentkit_loop::{ + Agent, LoopDriver, LoopError, LoopInterrupt, LoopObserver, LoopStep, SessionConfig, +}; use agentkit_task_manager::{AsyncTaskManager, RoutingDecision, TaskManager, TaskManagerHandle}; use agentkit_tool_compose::{ BackendRun, ComposeBackend, ComposeConfig, ComposeOutcome, ComposeTool, RunletBackend, @@ -80,11 +82,10 @@ impl SessionSelection { } fn claim_load(&mut self, id: &str) -> (SessionRequest, bool) { - let configured = !self.configured_claimed - && self - .configured - .as_ref() - .is_some_and(|request| request.id == id); + let matching_configured = self.configured.as_ref().filter(|request| request.id == id); + let configured = !self.configured_claimed && matching_configured.is_some(); + let force = configured + && matching_configured.is_some_and(|request| request.resume && request.force); if configured { self.configured_claimed = true; } @@ -92,7 +93,7 @@ impl SessionSelection { SessionRequest { id: id.into(), resume: true, - force: false, + force, }, configured, ) @@ -136,10 +137,10 @@ impl SessionSelection { } } -pub(crate) struct AcpDriverContext { +pub(crate) struct AcpDriverContext { pub cwd: PathBuf, pub additional_directories: Vec, - pub integration: Arc, + pub integration: Arc, pub cancellation: CancellationHandle, } @@ -161,6 +162,14 @@ impl SessionClaim { &self.request.id } + pub(crate) fn is_configured(&self) -> bool { + match self.kind { + SessionClaimKind::New { configured, .. } | SessionClaimKind::Load { configured } => { + configured + } + } + } + fn mark_opened(&mut self) { if let SessionClaimKind::New { opened_new, .. } = &mut self.kind { *opened_new = !self.request.resume; @@ -854,11 +863,14 @@ impl Runtime { }) } - pub(crate) async fn start_acp_driver( + pub(crate) async fn start_acp_driver( self: &Arc, - context: AcpDriverContext, + context: AcpDriverContext, claim: &mut SessionClaim, - ) -> Result { + ) -> Result + where + I: LoopObserver + Clone + 'static, + { let cwd = context .cwd .canonicalize() diff --git a/src/runtime/tests.rs b/src/runtime/tests.rs index aeb6772..ebb8230 100644 --- a/src/runtime/tests.rs +++ b/src/runtime/tests.rs @@ -215,15 +215,31 @@ fn successful_matching_load_consumes_configured_selection() { ) .unwrap(); - runtime - .claim_session_load("selected") - .unwrap() - .commit() - .unwrap(); + let selected = runtime.claim_session_load("selected").unwrap(); + assert!(selected.request.force); + selected.commit().unwrap(); let next = runtime.claim_session().unwrap(); assert_ne!(next.id(), "selected"); } +#[test] +fn nonmatching_load_never_inherits_configured_force() { + let root = tempfile::tempdir().unwrap(); + let runtime = Runtime::with_session( + root.path(), + "gpt-5.4", + SessionRequest { + id: "selected".into(), + resume: true, + force: true, + }, + ) + .unwrap(); + + let other = runtime.claim_session_load("other").unwrap(); + assert!(!other.request.force); +} + #[test] fn concurrent_successful_matching_load_consumes_configured_selection() { let root = tempfile::tempdir().unwrap(); diff --git a/src/session.rs b/src/session.rs index 74efaef..61bf887 100644 --- a/src/session.rs +++ b/src/session.rs @@ -3,7 +3,7 @@ use std::{ env, fs::{self, File, OpenOptions}, - io::{self, BufRead, BufReader, Seek, Write}, + io::{self, BufRead, BufReader, Seek, SeekFrom, Write}, path::{Path, PathBuf}, sync::{ Arc, Mutex, @@ -16,7 +16,9 @@ use agentkit_core::{Item, Timestamp}; use agentkit_loop::{TranscriptEvent, TranscriptObserver}; use serde::{Deserialize, Serialize}; -pub const SCHEMA_VERSION: u32 = 2; +pub const SCHEMA_VERSION: u32 = 3; +const REDIRECT_SCHEMA_VERSION: u32 = 4; +const PREVIOUS_SCHEMA_VERSION: u32 = 2; const LEGACY_SCHEMA_VERSION: u32 = 1; static NEXT_ID: AtomicU64 = AtomicU64::new(1); @@ -25,10 +27,14 @@ struct Record { schema_version: u32, session_id: String, generation: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_root: Option, #[serde(skip_serializing_if = "Option::is_none")] item: Option, #[serde(skip_serializing_if = "Option::is_none")] replacement: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + redirect: Option, } /// A loaded transcript together with the observer that owns its mutation lock. @@ -44,6 +50,7 @@ struct Writer { session_id: String, generation: u64, path: PathBuf, + workspace_root: PathBuf, file: File, lock: SessionLock, } @@ -108,8 +115,10 @@ pub(crate) fn load_in( session_id: &str, ) -> Result, String> { validate_id(session_id)?; - let path = preferred_transcript(directory, root, session_id)?; - let (mut items, _) = read_records(&path, session_id)?; + let workspace_root = canonical_workspace(root); + let mut items = select_authority(directory, &workspace_root, session_id)? + .ok_or_else(|| format!("session {session_id:?} does not exist"))? + .items; crate::transcript::repair_unanswered_tool_calls(&mut items); Ok(items) } @@ -146,13 +155,33 @@ pub(crate) fn clone_completed_in( /// /// This is the last-resort cleanup path for a hosting client whose server had /// to be killed before normal `SessionLock` destruction completed. -pub fn remove_stale_lock(_root: &Path, session_id: &str) -> Result<(), String> { - remove_stale_lock_in(&default_directory()?, session_id) +pub fn remove_stale_lock(root: &Path, session_id: &str) -> Result<(), String> { + remove_stale_lock_in(root, &default_directory()?, session_id) } -pub(crate) fn remove_stale_lock_in(directory: &Path, session_id: &str) -> Result<(), String> { +pub(crate) fn remove_stale_lock_in( + root: &Path, + directory: &Path, + session_id: &str, +) -> Result<(), String> { validate_id(session_id)?; - let path = lock_path(directory, session_id); + let workspace_root = canonical_workspace(root); + let scoped = lock_path( + &workspace_storage_directory(directory, &workspace_root), + session_id, + ); + let path = if scoped + .try_exists() + .map_err(|error| format!("could not inspect {}: {error}", scoped.display()))? + { + scoped + } else if let Some(transcript) = + legacy_transcript_for_workspace(directory, &workspace_root, session_id)? + { + transcript.with_extension("lock") + } else { + return Ok(()); + }; let file = match OpenOptions::new().read(true).write(true).open(&path) { Ok(file) => file, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), @@ -210,25 +239,23 @@ fn open_with_initial_timestamps_in( if !resume && initial.is_empty() { return Err("a new session requires an initial transcript".into()); } - fs::create_dir_all(directory) + let workspace_root = canonical_workspace(root); + let scoped_directory = workspace_storage_directory(directory, &workspace_root); + fs::create_dir_all(&scoped_directory) .map_err(|error| format!("could not create session directory: {error}"))?; - let path = transcript_path(directory, session_id); - let legacy = legacy_transcript(root, session_id); - let lock = SessionLock::acquire(lock_path(directory, session_id), force)?; - let global_exists = path - .try_exists() - .map_err(|error| format!("could not inspect {}: {error}", path.display()))?; - let legacy_exists = legacy - .try_exists() - .map_err(|error| format!("could not inspect {}: {error}", legacy.display()))?; - if resume && !global_exists { - if !legacy_exists { - return Err(format!("session {session_id:?} does not exist")); + let path = transcript_path(&scoped_directory, session_id); + let lock = SessionLock::acquire(lock_path(&scoped_directory, session_id), force)?; + let _migration_locks = lock_migration_sources(directory, &workspace_root, session_id)?; + recover_torn_migration_writes(&path, directory, &workspace_root, session_id)?; + let authority = select_authority(directory, &workspace_root, session_id)?; + if resume { + let authority = + authority.ok_or_else(|| format!("session {session_id:?} does not exist"))?; + establish_scoped_authority(&path, session_id, &workspace_root, &authority.items)?; + for legacy in authority.legacy_histories { + redirect_legacy_transcript(&legacy, &path, session_id, &workspace_root)?; } - let _legacy_lock = lock_legacy_for_migration(&legacy)?; - read_records(&legacy, session_id)?; - copy_new(&legacy, &path)?; - } else if !resume && (global_exists || legacy_exists) { + } else if authority.is_some() { return Err(format!( "session {session_id:?} already exists; use --resume" )); @@ -238,6 +265,19 @@ fn open_with_initial_timestamps_in( } else { (Vec::new(), 0) }; + let stored_workspace = resume + .then(|| transcript_workspace(&path, session_id)) + .transpose()? + .flatten(); + if let Some(stored) = &stored_workspace + && stored != &workspace_root + { + return Err(format!( + "session {session_id:?} belongs to workspace {}, not {}", + stored.display(), + workspace_root.display() + )); + } let mut options = OpenOptions::new(); options.read(true).append(true); if resume { @@ -253,9 +293,13 @@ fn open_with_initial_timestamps_in( session_id: session_id.into(), generation, path, + workspace_root, file, lock, }; + if resume && stored_workspace.is_none() { + writer.replace(&transcript)?; + } if !resume { for mut item in initial { if stamp_initial { @@ -333,8 +377,10 @@ impl Writer { schema_version: SCHEMA_VERSION, session_id: self.session_id.clone(), generation, + workspace_root: Some(self.workspace_root.clone()), item: Some(item.clone()), replacement: None, + redirect: None, }; self.write_record(record, generation) } @@ -349,8 +395,10 @@ impl Writer { schema_version: SCHEMA_VERSION, session_id: self.session_id.clone(), generation, + workspace_root: Some(self.workspace_root.clone()), item: None, replacement: Some(transcript.to_vec()), + redirect: None, }; self.write_record(record, generation) } @@ -452,6 +500,15 @@ impl SessionLock { path: PathBuf, force: bool, initialize: impl FnOnce(&mut File, &str) -> io::Result<()>, + ) -> Result { + Self::acquire_with_hook(path, force, || {}, initialize) + } + + fn acquire_with_hook( + path: PathBuf, + force: bool, + before_lock: impl FnOnce(), + initialize: impl FnOnce(&mut File, &str) -> io::Result<()>, ) -> Result { let token = format!("{}:{}:{}", std::process::id(), new_id(), SCHEMA_VERSION); let mut options = OpenOptions::new(); @@ -468,10 +525,12 @@ impl SessionLock { format!("could not acquire session lock {}: {error}", path.display()) } })?; + before_lock(); if file.try_lock().is_err() { - if !force { - remove_failed_lock(&path, file); - } + // A forced opener can take the OS lock after this process creates + // the pathname but before this call. It now owns that pathname, so + // the loser must close only and must never unlink it. + drop(file); return Err(format!( "session is actively locked by another Kit instance ({})", path.display() @@ -529,11 +588,44 @@ fn stamp_item(item: &mut Item, now: Timestamp) { } } +struct TranscriptHistory { + items: Vec, + generation: u64, + states: Vec>, +} + +enum StoredTranscript { + History(TranscriptHistory), + Redirect(PathBuf), +} + fn read_records(path: &Path, session_id: &str) -> Result<(Vec, u64), String> { + read_records_following(path, session_id, 0) +} + +fn read_records_following( + path: &Path, + session_id: &str, + redirects: usize, +) -> Result<(Vec, u64), String> { + match read_records_direct(path, session_id)? { + StoredTranscript::History(history) => Ok((history.items, history.generation)), + StoredTranscript::Redirect(target) => { + if redirects >= 4 || target.file_name() != path.file_name() || !target.is_absolute() { + return Err(format!("invalid session redirect in {}", path.display())); + } + read_records_following(&target, session_id, redirects + 1) + } + } +} + +fn read_records_direct(path: &Path, session_id: &str) -> Result { let file = File::open(path).map_err(|error| format!("could not read {}: {error}", path.display()))?; let mut items = Vec::new(); let mut expected = 1_u64; + let mut states = Vec::new(); + let mut redirect = None; for (index, line) in BufReader::new(file).lines().enumerate() { let line = line.map_err(|error| format!("could not read transcript line {}: {error}", index + 1))?; @@ -541,13 +633,16 @@ fn read_records(path: &Path, session_id: &str) -> Result<(Vec, u64), Strin .map_err(|error| format!("invalid transcript line {}: {error}", index + 1))?; if !matches!( record.schema_version, - LEGACY_SCHEMA_VERSION | SCHEMA_VERSION + LEGACY_SCHEMA_VERSION + | PREVIOUS_SCHEMA_VERSION + | SCHEMA_VERSION + | REDIRECT_SCHEMA_VERSION ) { return Err(format!( "unsupported session schema version {} on line {} (Kit supports {})", record.schema_version, index + 1, - SCHEMA_VERSION + REDIRECT_SCHEMA_VERSION )); } if record.session_id != session_id || record.generation != expected { @@ -556,26 +651,78 @@ fn read_records(path: &Path, session_id: &str) -> Result<(Vec, u64), Strin index + 1 )); } - match (record.item, record.replacement) { - (Some(item), None) => items.push(item), - (None, Some(replacement)) - if record.schema_version == SCHEMA_VERSION && !replacement.is_empty() => + if redirect.is_some() { + return Err(format!( + "session redirect must be the final transcript line ({})", + path.display() + )); + } + match (record.item, record.replacement, record.redirect) { + (Some(item), None, None) if record.schema_version <= SCHEMA_VERSION => items.push(item), + (None, Some(replacement), None) + if matches!( + record.schema_version, + PREVIOUS_SCHEMA_VERSION | SCHEMA_VERSION + ) && !replacement.is_empty() => { + if !items.is_empty() { + states.push(items.clone()); + } items = replacement; } + (None, None, Some(target)) + if record.schema_version == REDIRECT_SCHEMA_VERSION + && record.workspace_root.is_some() => + { + redirect = Some(target); + } _ => { return Err(format!( - "transcript line {} must contain exactly one item or replacement", + "transcript line {} must contain exactly one item, replacement, or redirect", index + 1 )); } } expected += 1; } + if let Some(target) = redirect { + return Ok(StoredTranscript::Redirect(target)); + } if items.is_empty() { return Err(format!("session transcript {} is empty", path.display())); } - Ok((items, expected - 1)) + states.push(items.clone()); + Ok(StoredTranscript::History(TranscriptHistory { + items, + generation: expected - 1, + states, + })) +} + +fn canonical_workspace(root: &Path) -> PathBuf { + if let Ok(canonical) = root.canonicalize() { + return canonical; + } + let mut ancestor = root.to_path_buf(); + let mut suffix = Vec::new(); + while let Some(name) = ancestor.file_name().map(ToOwned::to_owned) { + suffix.push(name); + if !ancestor.pop() { + return root.to_path_buf(); + } + if let Ok(mut canonical) = ancestor.canonicalize() { + for component in suffix.iter().rev() { + canonical.push(component); + } + return canonical; + } + } + root.to_path_buf() +} + +fn normalized_absolute(path: &Path) -> Result { + path.canonicalize() + .map_err(|error| format!("could not normalize {}: {error}", path.display())) } fn default_directory() -> Result { @@ -586,72 +733,551 @@ fn default_directory() -> Result { .ok_or_else(|| "HOME is unset; cannot locate durable sessions".into()) } -fn preferred_transcript( - directory: &Path, - root: &Path, +fn workspace_directory(root: &Path) -> PathBuf { + root.join(".kit/sessions") +} + +fn transcript_workspace(path: &Path, session_id: &str) -> Result, String> { + let bytes = + fs::read(path).map_err(|error| format!("could not read {}: {error}", path.display()))?; + transcript_workspace_bytes(path, session_id, &bytes) +} + +fn transcript_workspace_bytes( + path: &Path, session_id: &str, -) -> Result { - let global = transcript_path(directory, session_id); - if global - .try_exists() - .map_err(|error| format!("could not inspect {}: {error}", global.display()))? + bytes: &[u8], +) -> Result, String> { + let text = std::str::from_utf8(bytes) + .map_err(|error| format!("invalid transcript {}: {error}", path.display()))?; + let mut workspace = None; + for (index, line) in text.lines().enumerate() { + let record: Record = serde_json::from_str(line) + .map_err(|error| format!("invalid transcript line {}: {error}", index + 1))?; + if record.session_id != session_id { + return Err(format!( + "invalid session identity on transcript line {}", + index + 1 + )); + } + if record.workspace_root.is_some() { + workspace = record.workspace_root; + } + } + Ok(workspace) +} + +fn ensure_workspace(path: &Path, session_id: &str, root: &Path) -> Result<(), String> { + if let Some(stored) = transcript_workspace(path, session_id)? + && stored != root { - Ok(global) - } else { - Ok(legacy_transcript(root, session_id)) + return Err(format!( + "session {session_id:?} belongs to workspace {}, not {}", + stored.display(), + root.display() + )); } + Ok(()) } -fn lock_legacy_for_migration(transcript: &Path) -> Result, String> { - let path = transcript.with_extension("lock"); - let file = match OpenOptions::new().read(true).write(true).open(&path) { - Ok(file) => file, - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), +/// Lists durable transcript ids bound to one workspace without taking mutation locks. +pub(crate) fn list_ids(root: &Path) -> Result, String> { + list_ids_for_workspace(root, &default_directory()?) +} + +fn list_ids_for_workspace(root: &Path, global_directory: &Path) -> Result, String> { + let root = canonical_workspace(root); + let scoped_directory = workspace_storage_directory(global_directory, &root); + let legacy_directory = workspace_directory(&root); + let mut ids = Vec::new(); + for id in list_ids_in(&scoped_directory)? { + let path = transcript_path(&scoped_directory, &id); + read_records(&path, &id)?; + ensure_workspace(&path, &id, &root)?; + ids.push(id); + } + for id in list_ids_in(global_directory)? { + let path = transcript_path(global_directory, &id); + read_records(&path, &id)?; + let stored_workspace = transcript_workspace(&path, &id)?; + if stored_workspace.as_deref() == Some(root.as_path()) || stored_workspace.is_none() { + ids.push(id); + } + } + for id in list_ids_in(&legacy_directory)? { + let path = transcript_path(&legacy_directory, &id); + read_records(&path, &id)?; + ids.push(id); + } + ids.sort(); + ids.dedup(); + Ok(ids) +} + +pub(crate) fn belongs_to_workspace(root: &Path, session_id: &str) -> Result { + belongs_to_workspace_in(root, &default_directory()?, session_id) +} + +fn belongs_to_workspace_in( + root: &Path, + global_directory: &Path, + session_id: &str, +) -> Result { + validate_id(session_id)?; + let root = canonical_workspace(root); + Ok(select_authority(global_directory, &root, session_id)?.is_some()) +} + +pub(crate) fn list_ids_in(directory: &Path) -> Result, String> { + let entries = match fs::read_dir(directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), Err(error) => { return Err(format!( - "could not inspect legacy session lock {}: {error}", - path.display() + "could not list session directory {}: {error}", + directory.display() )); } }; - file.try_lock().map_err(|_| { - format!( - "legacy session is actively locked by another Kit instance ({}); stop it before resuming with this Kit version", - path.display() - ) - })?; - Ok(Some(file)) + let mut ids = Vec::new(); + for entry in entries { + let entry = entry.map_err(|error| format!("could not read session entry: {error}"))?; + let path = entry.path(); + if !entry + .file_type() + .map_err(|error| format!("could not inspect {}: {error}", path.display()))? + .is_file() + || path.extension().and_then(|value| value.to_str()) != Some("jsonl") + { + continue; + } + let Some(id) = path.file_stem().and_then(|value| value.to_str()) else { + continue; + }; + if validate_id(id).is_ok() { + ids.push(id.to_string()); + } + } + ids.sort(); + Ok(ids) +} + +struct Authority { + items: Vec, + legacy_histories: Vec, +} + +struct HistoryCandidate { + path: PathBuf, + history: TranscriptHistory, } -fn copy_new(source: &Path, destination: &Path) -> Result<(), String> { - let mut source = - File::open(source).map_err(|error| format!("could not read legacy session: {error}"))?; - let mut copied = OpenOptions::new() - .write(true) - .create_new(true) - .open(destination) +fn history_descends_from(history: &TranscriptHistory, ancestor: &[Item]) -> bool { + history + .states + .iter() + .any(|state| state.starts_with(ancestor)) +} + +fn select_authority( + directory: &Path, + root: &Path, + session_id: &str, +) -> Result, String> { + let scoped = transcript_path(&workspace_storage_directory(directory, root), session_id); + let global = transcript_path(directory, session_id); + let local = legacy_transcript(root, session_id); + let mut histories = Vec::new(); + let mut legacy_histories = Vec::new(); + + for (path, is_global, is_legacy) in [ + (&scoped, false, false), + (&global, true, true), + (&local, false, true), + ] { + if !path + .try_exists() + .map_err(|error| format!("could not inspect {}: {error}", path.display()))? + { + continue; + } + let workspace = transcript_workspace(path, session_id)?; + if is_global && workspace.as_deref().is_some_and(|stored| stored != root) { + continue; + } + if !is_global && workspace.as_deref().is_some_and(|stored| stored != root) { + return Err(format!( + "session {session_id:?} belongs to workspace {}, not {}", + workspace.unwrap().display(), + root.display() + )); + } + match read_records_direct(path, session_id)? { + StoredTranscript::History(history) => { + histories.push(HistoryCandidate { + path: path.clone(), + history, + }); + if is_legacy { + legacy_histories.push(path.clone()); + } + } + StoredTranscript::Redirect(target) => { + let target = normalized_absolute(&target)?; + let scoped_target = normalized_absolute(&scoped)?; + if target != scoped_target { + return Err(format!("invalid session redirect in {}", path.display())); + } + ensure_workspace(&target, session_id, root)?; + let StoredTranscript::History(history) = read_records_direct(&target, session_id)? + else { + return Err(format!( + "scoped transcript {} is a redirect", + target.display() + )); + }; + histories.push(HistoryCandidate { + path: target, + history, + }); + } + } + } + + let mut authority: Option = None; + for candidate in histories { + let Some(current) = authority.as_mut() else { + authority = Some(candidate); + continue; + }; + let candidate_descends = history_descends_from(&candidate.history, ¤t.history.items); + let current_descends = history_descends_from(¤t.history, &candidate.history.items); + match (candidate_descends, current_descends) { + (true, false) => *current = candidate, + (false, true) => {} + (true, true) if candidate.path == scoped => *current = candidate, + (true, true) => {} + (false, false) => { + return Err(format!( + "divergent session histories for {session_id:?}: {} and {}", + current.path.display(), + candidate.path.display() + )); + } + } + } + Ok(authority.map(|candidate| Authority { + items: candidate.history.items, + legacy_histories, + })) +} + +fn torn_migration_tail_start(bytes: &[u8]) -> Option { + if bytes.is_empty() || bytes.ends_with(b"\n") { + return None; + } + let start = bytes + .iter() + .rposition(|byte| *byte == b'\n') + .map_or(0, |index| index + 1); + serde_json::from_slice::(&bytes[start..]) + .is_err() + .then_some(start) +} + +fn migration_source_workspace(path: &Path, session_id: &str) -> Result, String> { + let bytes = + fs::read(path).map_err(|error| format!("could not read {}: {error}", path.display()))?; + let complete = torn_migration_tail_start(&bytes).unwrap_or(bytes.len()); + transcript_workspace_bytes(path, session_id, &bytes[..complete]) +} + +fn recover_torn_migration_writes( + scoped: &Path, + directory: &Path, + root: &Path, + session_id: &str, +) -> Result<(), String> { + let mut paths = applicable_migration_sources(directory, root, session_id)?; + paths.push(scoped.to_path_buf()); + paths.sort(); + paths.dedup(); + for path in paths { + let exists = path + .try_exists() + .map_err(|error| format!("could not inspect {}: {error}", path.display()))?; + if !exists { + continue; + } + let bytes = fs::read(&path) + .map_err(|error| format!("could not read {}: {error}", path.display()))?; + if bytes.is_empty() && path == scoped { + fs::remove_file(&path) + .map_err(|error| format!("could not remove {}: {error}", path.display()))?; + sync_parent_directory(&path)?; + continue; + } + let Some(complete) = torn_migration_tail_start(&bytes) else { + continue; + }; + if complete == 0 && path == scoped { + fs::remove_file(&path) + .map_err(|error| format!("could not remove {}: {error}", path.display()))?; + sync_parent_directory(&path)?; + } else { + let file = OpenOptions::new() + .write(true) + .open(&path) + .map_err(|error| format!("could not open {}: {error}", path.display()))?; + file.set_len(complete as u64) + .and_then(|_| file.sync_all()) + .map_err(|error| { + format!( + "could not recover torn migration {}: {error}", + path.display() + ) + })?; + } + } + Ok(()) +} + +#[cfg(unix)] +fn sync_parent_directory(path: &Path) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("{} has no parent directory", path.display()))?; + File::open(parent) + .and_then(|directory| directory.sync_all()) .map_err(|error| { format!( - "could not copy legacy session to {}: {error}", - destination.display() + "could not sync session directory {}: {error}", + parent.display() ) - })?; - if let Err(error) = io::copy(&mut source, &mut copied).and_then(|_| copied.sync_all()) { - let _ = fs::remove_file(destination); - return Err(format!( - "could not copy legacy session to {}: {error}", - destination.display() - )); + }) +} + +#[cfg(not(unix))] +fn sync_parent_directory(_path: &Path) -> Result<(), String> { + Ok(()) +} + +fn applicable_migration_sources( + directory: &Path, + root: &Path, + session_id: &str, +) -> Result, String> { + let global = transcript_path(directory, session_id); + let mut sources = vec![legacy_transcript(root, session_id)]; + let global_exists = global + .try_exists() + .map_err(|error| format!("could not inspect {}: {error}", global.display()))?; + if !global_exists + || migration_source_workspace(&global, session_id)? + .as_deref() + .is_none_or(|stored| stored == root) + { + sources.push(global); + } + sources.sort(); + sources.dedup(); + Ok(sources) +} + +fn lock_migration_sources( + directory: &Path, + root: &Path, + session_id: &str, +) -> Result, String> { + let mut locks = Vec::new(); + let mut locked_paths = Vec::new(); + loop { + let sources = applicable_migration_sources(directory, root, session_id)?; + let pending = sources + .into_iter() + .map(|path| path.with_extension("lock")) + .filter(|path| !locked_paths.contains(path)) + .collect::>(); + if pending.is_empty() { + return Ok(locks); + } + for path in pending { + fs::create_dir_all(path.parent().expect("session lock has a parent")) + .map_err(|error| format!("could not create legacy session directory: {error}"))?; + let lock = SessionLock::acquire(path.clone(), true) + .map_err(|error| format!("legacy {error}"))?; + locked_paths.push(path); + locks.push(lock); + } + // Re-read source ownership while the applicable locks are held. If a + // previously absent source appeared, the next iteration locks it too. + } +} + +fn write_migration_record(path: &Path, record: &Record, create: bool) -> Result<(), String> { + write_migration_record_with(path, record, create, |file, encoded| { + file.write_all(encoded) + }) +} + +fn write_migration_record_with( + path: &Path, + record: &Record, + create: bool, + write: impl FnOnce(&mut File, &[u8]) -> io::Result<()>, +) -> Result<(), String> { + let mut encoded = serde_json::to_vec(record) + .map_err(|error| format!("could not encode transcript record: {error}"))?; + encoded.push(b'\n'); + let mut options = OpenOptions::new(); + options.read(true).write(true); + if create { + options.create_new(true); + } + let mut file = options + .open(path) + .map_err(|error| format!("could not open {}: {error}", path.display()))?; + let original_len = file + .metadata() + .map_err(|error| format!("could not inspect {}: {error}", path.display()))? + .len(); + if !create { + file.seek(SeekFrom::End(0)) + .map_err(|error| format!("could not seek {}: {error}", path.display()))?; + } + if let Err(error) = write(&mut file, &encoded).and_then(|_| file.sync_all()) { + let rollback = if create { + drop(file); + fs::remove_file(path) + } else { + file.set_len(original_len).and_then(|_| file.sync_all()) + }; + return match rollback { + Ok(()) => Err(format!("could not persist transcript migration: {error}")), + Err(rollback) => Err(format!( + "could not persist transcript migration: {error}; rollback failed: {rollback}" + )), + }; + } + if create { + sync_parent_directory(path)?; } Ok(()) } +fn establish_scoped_authority( + path: &Path, + session_id: &str, + root: &Path, + items: &[Item], +) -> Result<(), String> { + let exists = path + .try_exists() + .map_err(|error| format!("could not inspect {}: {error}", path.display()))?; + let generation = if exists { + match read_records_direct(path, session_id)? { + StoredTranscript::History(history) + if history.items == items + && transcript_workspace(path, session_id)?.as_deref() == Some(root) => + { + return Ok(()); + } + StoredTranscript::History(history) => history + .generation + .checked_add(1) + .ok_or_else(|| "session generation overflowed".to_string())?, + StoredTranscript::Redirect(_) => { + return Err(format!( + "scoped transcript {} is a redirect", + path.display() + )); + } + } + } else { + 1 + }; + write_migration_record( + path, + &Record { + schema_version: SCHEMA_VERSION, + session_id: session_id.into(), + generation, + workspace_root: Some(root.to_path_buf()), + item: None, + replacement: Some(items.to_vec()), + redirect: None, + }, + !exists, + ) +} + +fn redirect_legacy_transcript( + path: &Path, + target: &Path, + session_id: &str, + root: &Path, +) -> Result<(), String> { + let target = normalized_absolute(target)?; + let generation = match read_records_direct(path, session_id)? { + StoredTranscript::History(history) => history + .generation + .checked_add(1) + .ok_or_else(|| "session generation overflowed".to_string())?, + StoredTranscript::Redirect(current) if normalized_absolute(¤t)? == target => { + return Ok(()); + } + StoredTranscript::Redirect(_) => { + return Err(format!("invalid session redirect in {}", path.display())); + } + }; + write_migration_record( + path, + &Record { + schema_version: REDIRECT_SCHEMA_VERSION, + session_id: session_id.into(), + generation, + workspace_root: Some(root.to_path_buf()), + item: None, + replacement: None, + redirect: Some(target), + }, + false, + ) +} + +fn legacy_transcript_for_workspace( + directory: &Path, + root: &Path, + session_id: &str, +) -> Result, String> { + let global = transcript_path(directory, session_id); + if global.exists() + && transcript_workspace(&global, session_id)? + .as_deref() + .is_none_or(|stored| stored == root) + { + return Ok(Some(global)); + } + let local = legacy_transcript(root, session_id); + if local.exists() { + read_records(&local, session_id)?; + Ok(Some(local)) + } else { + Ok(None) + } +} + +fn workspace_storage_directory(directory: &Path, root: &Path) -> PathBuf { + let identity = blake3::hash(root.as_os_str().as_encoded_bytes()); + directory.join(format!("w-{}", identity.to_hex())) +} + fn transcript_path(directory: &Path, session_id: &str) -> PathBuf { directory.join(format!("{session_id}.jsonl")) } fn legacy_transcript(root: &Path, session_id: &str) -> PathBuf { - transcript_path(&root.join(".kit/sessions"), session_id) + transcript_path(&workspace_directory(root), session_id) } fn lock_path(directory: &Path, session_id: &str) -> PathBuf { @@ -715,11 +1341,69 @@ mod tests { } fn remove_stale_lock(root: &Path, session_id: &str) -> Result<(), String> { - remove_stale_lock_in(&session_directory(root), session_id) + remove_stale_lock_in(&project_root(root), &session_directory(root), session_id) + } + + fn scoped_directory(root: &Path) -> PathBuf { + workspace_storage_directory( + &session_directory(root), + &canonical_workspace(&project_root(root)), + ) + } + + fn legacy_directory(root: &Path) -> PathBuf { + workspace_directory(&canonical_workspace(&project_root(root))) } fn transcript_path(root: &Path, session_id: &str) -> PathBuf { - super::transcript_path(&session_directory(root), session_id) + super::transcript_path(&scoped_directory(root), session_id) + } + + fn session_lock_path(root: &Path, session_id: &str) -> PathBuf { + super::lock_path(&scoped_directory(root), session_id) + } + + fn item_text(item: &Item) -> &str { + let Some(Part::Text(text)) = item.parts.first() else { + panic!("expected text item"); + }; + &text.text + } + + fn write_history( + path: &Path, + schema_version: u32, + session_id: &str, + texts: &[&str], + workspace_root: Option, + ) -> Vec { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let items = texts + .iter() + .enumerate() + .map(|(index, text)| { + Item::text(ItemKind::System, *text).with_created_at(Timestamp((index + 1) as u64)) + }) + .collect::>(); + let encoded = items + .iter() + .enumerate() + .map(|(index, item)| { + serde_json::to_string(&Record { + schema_version, + session_id: session_id.into(), + generation: (index + 1) as u64, + workspace_root: workspace_root.clone(), + item: Some(item.clone()), + replacement: None, + redirect: None, + }) + .unwrap() + }) + .collect::>() + .join("\n"); + fs::write(path, format!("{encoded}\n")).unwrap(); + items } #[test] @@ -786,6 +1470,30 @@ mod tests { assert!(!path.exists()); } + #[test] + fn non_force_lock_loser_does_not_unlink_forced_owner() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("takeover.lock"); + let takeover = std::cell::RefCell::new(None); + + let error = SessionLock::acquire_with_hook( + path.clone(), + false, + || { + *takeover.borrow_mut() = Some(SessionLock::acquire(path.clone(), true).unwrap()); + }, + |file, token| file.write_all(token.as_bytes()), + ) + .err() + .expect("non-force opener unexpectedly retained the OS lock"); + + assert!(error.contains("actively locked")); + assert!(path.exists(), "lock loser unlinked the forced owner's path"); + assert!(takeover.borrow().as_ref().unwrap().check().is_ok()); + drop(takeover.into_inner()); + assert!(!path.exists()); + } + #[test] fn appends_versioned_generations_and_resumes() { let root = tempfile::tempdir().unwrap(); @@ -814,8 +1522,9 @@ mod tests { assert!(resumed.transcript[0].created_at.is_some()); assert_eq!(resumed.transcript[1].created_at, Some(Timestamp(123))); let text = fs::read_to_string(transcript_path(root.path(), "abc")).unwrap(); - assert!(text.contains("\"schema_version\":2")); + assert!(text.contains(&format!("\"schema_version\":{SCHEMA_VERSION}"))); assert!(text.contains("\"generation\":2")); + assert!(text.contains("\"workspace_root\"")); } #[test] @@ -870,7 +1579,7 @@ mod tests { #[test] fn reads_legacy_null_and_missing_timestamps() { let root = tempfile::tempdir().unwrap(); - let directory = session_directory(root.path()); + let directory = legacy_directory(root.path()); fs::create_dir_all(&directory).unwrap(); let mut missing = serde_json::to_value(Item::text(ItemKind::System, "missing")).unwrap(); missing.as_object_mut().unwrap().remove("created_at"); @@ -904,7 +1613,7 @@ mod tests { #[test] fn cloning_preserves_historical_unknown_timestamps() { let root = tempfile::tempdir().unwrap(); - let directory = session_directory(root.path()); + let directory = legacy_directory(root.path()); fs::create_dir_all(&directory).unwrap(); let lines = [ serde_json::json!({ @@ -1133,9 +1842,9 @@ mod tests { "force must not steal authority from a live owner" ); drop(first); - fs::write(session_directory(root.path()).join("abc.lock"), "abandoned").unwrap(); + fs::write(session_lock_path(root.path(), "abc"), "abandoned").unwrap(); remove_stale_lock(root.path(), "abc").unwrap(); - assert!(!session_directory(root.path()).join("abc.lock").exists()); + assert!(!session_lock_path(root.path(), "abc").exists()); assert!( open( root.path(), @@ -1158,8 +1867,10 @@ mod tests { schema_version: SCHEMA_VERSION, session_id: "abc".into(), generation: 1, + workspace_root: None, item: Some(item.clone()), replacement: None, + redirect: None, }; fs::write( legacy.join("abc.jsonl"), @@ -1196,7 +1907,356 @@ mod tests { } #[test] - fn global_transcript_wins_over_legacy() { + fn schema_one_and_two_global_only_transcripts_migrate() { + let root = tempfile::tempdir().unwrap(); + for (schema, session_id) in [ + (LEGACY_SCHEMA_VERSION, "schema-one"), + (PREVIOUS_SCHEMA_VERSION, "schema-two"), + ] { + let global = super::transcript_path(&session_directory(root.path()), session_id); + let expected = write_history(&global, schema, session_id, &["global"], None); + + assert_eq!(load(root.path(), session_id).unwrap(), expected); + let opened = open(root.path(), session_id, true, false, Vec::new()).unwrap(); + assert_eq!(opened.transcript, expected); + drop(opened); + + let scoped = transcript_path(root.path(), session_id); + assert!(scoped.is_file()); + assert!(matches!( + read_records_direct(&global, session_id).unwrap(), + StoredTranscript::Redirect(target) if target == normalized_absolute(&scoped).unwrap() + )); + } + } + + #[test] + fn migrated_redirect_roundtrips_through_current_readers() { + let root = tempfile::tempdir().unwrap(); + let global = super::transcript_path(&session_directory(root.path()), "abc"); + write_history(&global, PREVIOUS_SCHEMA_VERSION, "abc", &["before"], None); + + let opened = open(root.path(), "abc", true, false, Vec::new()).unwrap(); + write(&opened.observer, &Item::text(ItemKind::User, "after")); + drop(opened); + + assert_eq!(read_records(&global, "abc").unwrap().0.len(), 2); + assert_eq!(load(root.path(), "abc").unwrap().len(), 2); + } + + #[test] + fn unrelated_live_global_lock_does_not_block_another_workspace() { + let storage = tempfile::tempdir().unwrap(); + let roots = tempfile::tempdir().unwrap(); + let first = roots.path().join("first"); + let second = roots.path().join("second"); + fs::create_dir_all(&first).unwrap(); + fs::create_dir_all(&second).unwrap(); + let global = super::transcript_path(storage.path(), "shared"); + write_history( + &global, + SCHEMA_VERSION, + "shared", + &["first"], + Some(canonical_workspace(&first)), + ); + let global_lock_path = global.with_extension("lock"); + let global_lock = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(&global_lock_path) + .unwrap(); + global_lock.try_lock().unwrap(); + + let opened = open_in( + &second, + storage.path(), + "shared", + false, + false, + vec![Item::text(ItemKind::System, "second")], + ) + .unwrap(); + assert_eq!(item_text(&opened.transcript[0]), "second"); + drop(opened); + + global_lock.unlock().unwrap(); + drop(global_lock); + fs::remove_file(global_lock_path).unwrap(); + } + + #[test] + fn failed_tombstone_append_rolls_back_partial_record() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("abc.jsonl"); + write_history(&path, PREVIOUS_SCHEMA_VERSION, "abc", &["original"], None); + let before = fs::read(&path).unwrap(); + let _lock = SessionLock::acquire(path.with_extension("lock"), true).unwrap(); + let record = Record { + schema_version: REDIRECT_SCHEMA_VERSION, + session_id: "abc".into(), + generation: 2, + workspace_root: Some(root.path().to_path_buf()), + item: None, + replacement: None, + redirect: Some(root.path().join("scoped/abc.jsonl")), + }; + + let error = write_migration_record_with(&path, &record, false, |file, encoded| { + file.write_all(&encoded[..encoded.len() / 2])?; + Err(io::Error::other("injected tombstone failure")) + }) + .unwrap_err(); + + assert!(error.contains("injected tombstone failure")); + assert_eq!(fs::read(&path).unwrap(), before); + assert!(matches!( + read_records_direct(&path, "abc").unwrap(), + StoredTranscript::History(TranscriptHistory { generation: 1, .. }) + )); + } + + #[test] + fn relative_session_directory_migration_persists_absolute_redirect() { + let current = env::current_dir().unwrap(); + let owner = tempfile::tempdir_in(¤t).unwrap(); + let relative_owner = owner.path().strip_prefix(¤t).unwrap(); + let directory = relative_owner.join("sessions"); + let root = owner.path().join("project"); + fs::create_dir_all(&root).unwrap(); + let global = super::transcript_path(&directory, "abc"); + let expected = write_history(&global, PREVIOUS_SCHEMA_VERSION, "abc", &["relative"], None); + + drop(open_in(&root, &directory, "abc", true, false, Vec::new()).unwrap()); + + let target = match read_records_direct(&global, "abc").unwrap() { + StoredTranscript::Redirect(target) => target, + StoredTranscript::History(_) => panic!("legacy transcript was not redirected"), + }; + assert!(target.is_absolute()); + assert_eq!(target, target.canonicalize().unwrap()); + assert_eq!(load_in(&root, &directory, "abc").unwrap(), expected); + } + + #[test] + fn newer_global_history_extends_stale_scoped_and_workspace_local_histories() { + let root = tempfile::tempdir().unwrap(); + let global = super::transcript_path(&session_directory(root.path()), "abc"); + let local = super::transcript_path(&legacy_directory(root.path()), "abc"); + write_history( + &transcript_path(root.path(), "abc"), + SCHEMA_VERSION, + "abc", + &["first"], + Some(canonical_workspace(&project_root(root.path()))), + ); + write_history(&local, LEGACY_SCHEMA_VERSION, "abc", &["first"], None); + let expected = write_history( + &global, + PREVIOUS_SCHEMA_VERSION, + "abc", + &["first", "newer"], + None, + ); + + let opened = open(root.path(), "abc", true, false, Vec::new()).unwrap(); + assert_eq!(opened.transcript, expected); + drop(opened); + assert!(matches!( + read_records_direct(&global, "abc").unwrap(), + StoredTranscript::Redirect(_) + )); + assert!(matches!( + read_records_direct(&local, "abc").unwrap(), + StoredTranscript::Redirect(_) + )); + } + + #[test] + fn scoped_replacement_descends_from_materialized_stale_legacy_history() { + let root = tempfile::tempdir().unwrap(); + let global = super::transcript_path(&session_directory(root.path()), "abc"); + let stale = write_history( + &global, + PREVIOUS_SCHEMA_VERSION, + "abc", + &["before-one", "before-two"], + None, + ); + let scoped = transcript_path(root.path(), "abc"); + write_history( + &scoped, + SCHEMA_VERSION, + "abc", + &["before-one", "before-two"], + Some(canonical_workspace(&project_root(root.path()))), + ); + let compacted = vec![ + Item::text(ItemKind::Context, "compacted newer state").with_created_at(Timestamp(9)), + ]; + write_migration_record( + &scoped, + &Record { + schema_version: SCHEMA_VERSION, + session_id: "abc".into(), + generation: stale.len() as u64 + 1, + workspace_root: Some(canonical_workspace(&project_root(root.path()))), + item: None, + replacement: Some(compacted.clone()), + redirect: None, + }, + false, + ) + .unwrap(); + + assert_eq!(load(root.path(), "abc").unwrap(), compacted); + let opened = open(root.path(), "abc", true, false, Vec::new()).unwrap(); + assert_eq!(opened.transcript, compacted); + drop(opened); + assert!(matches!( + read_records_direct(&global, "abc").unwrap(), + StoredTranscript::Redirect(_) + )); + } + + #[test] + fn torn_new_scoped_authority_is_removed_and_recreated_from_legacy() { + let root = tempfile::tempdir().unwrap(); + let global = super::transcript_path(&session_directory(root.path()), "abc"); + let expected = write_history(&global, PREVIOUS_SCHEMA_VERSION, "abc", &["legacy"], None); + let scoped = transcript_path(root.path(), "abc"); + fs::create_dir_all(scoped.parent().unwrap()).unwrap(); + let record = Record { + schema_version: SCHEMA_VERSION, + session_id: "abc".into(), + generation: 1, + workspace_root: Some(canonical_workspace(&project_root(root.path()))), + item: None, + replacement: Some(expected.clone()), + redirect: None, + }; + let encoded = serde_json::to_vec(&record).unwrap(); + fs::write(&scoped, &encoded[..encoded.len() / 2]).unwrap(); + + assert!(load(root.path(), "abc").is_err()); + let opened = open(root.path(), "abc", true, false, Vec::new()).unwrap(); + assert_eq!(opened.transcript, expected); + drop(opened); + assert_eq!(load(root.path(), "abc").unwrap(), expected); + } + + #[test] + fn torn_redirect_is_truncated_and_retried() { + let root = tempfile::tempdir().unwrap(); + let global = super::transcript_path(&session_directory(root.path()), "abc"); + let expected = write_history(&global, PREVIOUS_SCHEMA_VERSION, "abc", &["shared"], None); + let scoped = transcript_path(root.path(), "abc"); + write_history( + &scoped, + SCHEMA_VERSION, + "abc", + &["shared"], + Some(canonical_workspace(&project_root(root.path()))), + ); + let record = Record { + schema_version: REDIRECT_SCHEMA_VERSION, + session_id: "abc".into(), + generation: 2, + workspace_root: Some(canonical_workspace(&project_root(root.path()))), + item: None, + replacement: None, + redirect: Some(normalized_absolute(&scoped).unwrap()), + }; + let encoded = serde_json::to_vec(&record).unwrap(); + OpenOptions::new() + .append(true) + .open(&global) + .unwrap() + .write_all(&encoded[..encoded.len() / 2]) + .unwrap(); + + assert!(load(root.path(), "abc").is_err()); + let opened = open(root.path(), "abc", true, false, Vec::new()).unwrap(); + assert_eq!(opened.transcript, expected); + drop(opened); + assert!(matches!( + read_records_direct(&global, "abc").unwrap(), + StoredTranscript::Redirect(_) + )); + assert_eq!(load(root.path(), "abc").unwrap(), expected); + } + + #[test] + fn downgraded_reader_rejects_tombstone_before_write_and_reupgrade_resumes() { + let root = tempfile::tempdir().unwrap(); + let global = super::transcript_path(&session_directory(root.path()), "abc"); + write_history(&global, PREVIOUS_SCHEMA_VERSION, "abc", &["original"], None); + drop(open(root.path(), "abc", true, false, Vec::new()).unwrap()); + let before = fs::read(&global).unwrap(); + + let downgraded_append = || -> Result<(), String> { + let mut generation = 0; + for line in BufReader::new(File::open(&global).unwrap()).lines() { + let record: Record = serde_json::from_str(&line.unwrap()).unwrap(); + if record.schema_version > SCHEMA_VERSION { + return Err("unsupported schema".into()); + } + generation = record.generation; + } + write_migration_record( + &global, + &Record { + schema_version: SCHEMA_VERSION, + session_id: "abc".into(), + generation: generation + 1, + workspace_root: None, + item: Some(Item::text(ItemKind::User, "downgraded write")), + replacement: None, + redirect: None, + }, + false, + ) + }; + assert_eq!(downgraded_append().unwrap_err(), "unsupported schema"); + assert_eq!(fs::read(&global).unwrap(), before); + + let reopened = open(root.path(), "abc", true, false, Vec::new()).unwrap(); + assert_eq!(item_text(&reopened.transcript[0]), "original"); + } + + #[test] + fn migration_honors_global_and_workspace_local_mixed_version_locks() { + let root = tempfile::tempdir().unwrap(); + let global = super::transcript_path(&session_directory(root.path()), "abc"); + let local = super::transcript_path(&legacy_directory(root.path()), "abc"); + write_history(&global, PREVIOUS_SCHEMA_VERSION, "abc", &["same"], None); + write_history(&local, LEGACY_SCHEMA_VERSION, "abc", &["same"], None); + + for lock_path in [global.with_extension("lock"), local.with_extension("lock")] { + let lock = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(&lock_path) + .unwrap(); + lock.try_lock().unwrap(); + assert!( + open(root.path(), "abc", true, false, Vec::new()) + .err() + .unwrap() + .contains("legacy session is actively locked") + ); + lock.unlock().unwrap(); + drop(lock); + fs::remove_file(lock_path).unwrap(); + } + + assert!(open(root.path(), "abc", true, false, Vec::new()).is_ok()); + } + + #[test] + fn divergent_scoped_and_workspace_local_histories_are_rejected() { let root = tempfile::tempdir().unwrap(); let global = open( root.path(), @@ -1206,7 +2266,6 @@ mod tests { vec![Item::text(ItemKind::System, "global")], ) .unwrap(); - let expected = global.transcript.clone(); drop(global); let legacy = project_root(root.path()).join(".kit/sessions"); fs::create_dir_all(&legacy).unwrap(); @@ -1214,8 +2273,10 @@ mod tests { schema_version: SCHEMA_VERSION, session_id: "abc".into(), generation: 1, + workspace_root: None, item: Some(Item::text(ItemKind::System, "legacy")), replacement: None, + redirect: None, }; fs::write( legacy.join("abc.jsonl"), @@ -1223,7 +2284,14 @@ mod tests { ) .unwrap(); - assert_eq!(load(root.path(), "abc").unwrap(), expected); + let error = load(root.path(), "abc").unwrap_err(); + assert!(error.contains("divergent session histories")); + assert!( + open(root.path(), "abc", true, false, Vec::new()) + .err() + .unwrap() + .contains("divergent session histories") + ); } #[test] @@ -1243,7 +2311,7 @@ mod tests { write(&opened.observer, &Item::text(ItemKind::Assistant, "after")); write(&opened.observer, &Item::text(ItemKind::User, "continued")); - assert!(session_directory(root.path()).join("abc.lock").is_file()); + assert!(session_lock_path(root.path(), "abc").is_file()); assert_eq!(stored(root.path()).len(), 4); drop(opened); assert_eq!( @@ -1272,7 +2340,203 @@ mod tests { write(&opened.observer, &Item::text(ItemKind::Assistant, "after")); assert_eq!(stored(root.path()).len(), 3); - assert!(session_directory(root.path()).join("abc.lock").is_file()); + assert!(session_lock_path(root.path(), "abc").is_file()); + } + + #[test] + fn list_ids_ignores_non_transcripts_and_sorts_valid_ids() { + let directory = tempfile::tempdir().unwrap(); + fs::write(directory.path().join("zeta.jsonl"), "transcript").unwrap(); + fs::write(directory.path().join("alpha.jsonl"), "transcript").unwrap(); + fs::write(directory.path().join("active.lock"), "lock").unwrap(); + fs::write(directory.path().join("bad id.jsonl"), "invalid").unwrap(); + fs::create_dir(directory.path().join("nested.jsonl")).unwrap(); + + assert_eq!(list_ids_in(directory.path()).unwrap(), ["alpha", "zeta"]); + } + + #[test] + fn identical_ids_round_trip_independently_across_workspaces() { + let storage = tempfile::tempdir().unwrap(); + let roots = tempfile::tempdir().unwrap(); + let first = roots.path().join("first"); + let second = roots.path().join("second"); + fs::create_dir_all(&first).unwrap(); + fs::create_dir_all(&second).unwrap(); + + let first_open = open_in( + &first, + storage.path(), + "shared", + false, + false, + vec![Item::text(ItemKind::System, "first")], + ) + .unwrap(); + let second_open = open_in( + &second, + storage.path(), + "shared", + false, + false, + vec![Item::text(ItemKind::System, "second")], + ) + .unwrap(); + write( + &first_open.observer, + &Item::text(ItemKind::User, "first-only"), + ); + write( + &second_open.observer, + &Item::text(ItemKind::User, "second-only"), + ); + + let first_path = super::transcript_path( + &workspace_storage_directory(storage.path(), &canonical_workspace(&first)), + "shared", + ); + let second_path = super::transcript_path( + &workspace_storage_directory(storage.path(), &canonical_workspace(&second)), + "shared", + ); + assert_ne!(first_path, second_path); + assert!(first_path.with_extension("lock").is_file()); + assert!(second_path.with_extension("lock").is_file()); + assert_eq!( + item_text(&load_in(&first, storage.path(), "shared").unwrap()[0]), + "first" + ); + assert_eq!( + item_text(&load_in(&second, storage.path(), "shared").unwrap()[0]), + "second" + ); + drop(first_open); + drop(second_open); + + let first_resumed = + open_in(&first, storage.path(), "shared", true, false, Vec::new()).unwrap(); + let second_resumed = + open_in(&second, storage.path(), "shared", true, false, Vec::new()).unwrap(); + assert_eq!(item_text(&first_resumed.transcript[1]), "first-only"); + assert_eq!(item_text(&second_resumed.transcript[1]), "second-only"); + assert_eq!( + list_ids_for_workspace(&first, storage.path()).unwrap(), + ["shared"] + ); + assert_eq!( + list_ids_for_workspace(&second, storage.path()).unwrap(), + ["shared"] + ); + } + + #[test] + fn unscoped_transcript_migrates_without_blocking_same_id_in_another_workspace() { + let storage = tempfile::tempdir().unwrap(); + let roots = tempfile::tempdir().unwrap(); + let first = roots.path().join("first"); + let second = roots.path().join("second"); + fs::create_dir_all(&first).unwrap(); + fs::create_dir_all(&second).unwrap(); + let item = Item::text(ItemKind::System, "unscoped").with_created_at(Timestamp(7)); + let record = Record { + schema_version: SCHEMA_VERSION, + session_id: "legacy-id".into(), + generation: 1, + workspace_root: Some(canonical_workspace(&first)), + item: Some(item.clone()), + replacement: None, + redirect: None, + }; + let unscoped = super::transcript_path(storage.path(), "legacy-id"); + fs::write( + &unscoped, + format!("{}\n", serde_json::to_string(&record).unwrap()), + ) + .unwrap(); + + assert_eq!( + load_in(&first, storage.path(), "legacy-id").unwrap(), + [item] + ); + assert!(load_in(&second, storage.path(), "legacy-id").is_err()); + assert_eq!( + list_ids_for_workspace(&first, storage.path()).unwrap(), + ["legacy-id"] + ); + assert!( + list_ids_for_workspace(&second, storage.path()) + .unwrap() + .is_empty() + ); + let migrated = + open_in(&first, storage.path(), "legacy-id", true, false, Vec::new()).unwrap(); + drop(migrated); + assert!(unscoped.is_file(), "migration must retain the old artifact"); + let scoped = super::transcript_path( + &workspace_storage_directory(storage.path(), &canonical_workspace(&first)), + "legacy-id", + ); + assert!(scoped.is_file()); + + let second_open = open_in( + &second, + storage.path(), + "legacy-id", + false, + false, + vec![Item::text(ItemKind::System, "second")], + ) + .unwrap(); + assert_eq!(item_text(&second_open.transcript[0]), "second"); + assert_eq!( + list_ids_for_workspace(&first, storage.path()).unwrap(), + ["legacy-id"] + ); + assert_eq!( + list_ids_for_workspace(&second, storage.path()).unwrap(), + ["legacy-id"] + ); + } + + #[test] + fn legacy_transcript_is_listed_only_in_its_project_and_binds_on_resume() { + let storage = tempfile::tempdir().unwrap(); + let roots = tempfile::tempdir().unwrap(); + let first = roots.path().join("first"); + let second = roots.path().join("second"); + let legacy = workspace_directory(&first); + fs::create_dir_all(&legacy).unwrap(); + fs::create_dir_all(&second).unwrap(); + let record = serde_json::json!({ + "schema_version": PREVIOUS_SCHEMA_VERSION, + "session_id": "legacy", + "generation": 1, + "item": Item::text(ItemKind::System, "legacy"), + }); + fs::write(legacy.join("legacy.jsonl"), format!("{record}\n")).unwrap(); + + assert_eq!( + list_ids_for_workspace(&first, storage.path()).unwrap(), + ["legacy"] + ); + assert!( + list_ids_for_workspace(&second, storage.path()) + .unwrap() + .is_empty() + ); + assert!(belongs_to_workspace_in(&first, storage.path(), "legacy").unwrap()); + + let opened = open_in(&first, storage.path(), "legacy", true, false, Vec::new()).unwrap(); + drop(opened); + let migrated = super::transcript_path( + &workspace_storage_directory(storage.path(), &canonical_workspace(&first)), + "legacy", + ); + assert_eq!( + transcript_workspace(&migrated, "legacy").unwrap(), + Some(canonical_workspace(&first)) + ); + assert!(!belongs_to_workspace_in(&second, storage.path(), "legacy").unwrap()); } #[test] @@ -1287,12 +2551,8 @@ mod tests { ) .unwrap(); fs::remove_dir_all(session_directory(root.path())).unwrap(); - fs::create_dir_all(session_directory(root.path())).unwrap(); - let other = SessionLock::acquire( - super::lock_path(&session_directory(root.path()), "abc"), - false, - ) - .unwrap(); + fs::create_dir_all(scoped_directory(root.path())).unwrap(); + let other = SessionLock::acquire(session_lock_path(root.path(), "abc"), false).unwrap(); let item = Item::text(ItemKind::User, "must not persist").with_created_at(Timestamp(9)); let error = opened.observer.0.lock().unwrap().append(&item).unwrap_err(); diff --git a/src/tui/app.rs b/src/tui/app.rs index 654d301..9302bbc 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -2,7 +2,7 @@ use std::{ cmp::Reverse, - collections::BTreeSet, + collections::{BTreeSet, HashMap, VecDeque}, ops::Range, path::PathBuf, time::{Duration, Instant}, @@ -13,17 +13,22 @@ use std::ffi::OsStr; #[cfg(any(target_os = "macos", target_os = "linux"))] use std::process::{Command, Stdio}; -use agentkit_acp::{ToolCallStatus, ToolKind}; +use agent_client_protocol::schema::v2::{StopReason, ToolCallStatus, ToolKind}; +#[cfg(test)] use agentkit_core::{DataRef, Item, ItemKind, Modality, Part, ToolOutput}; use crossterm::event::{ KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; use ratatui::text::Line; -use crate::{compaction::is_compaction_summary, events::RuntimeEvent}; +#[cfg(test)] +use crate::compaction::is_compaction_summary; +use crate::events::RuntimeEvent; + +const MAX_TOOL_OUTPUT_LINES: usize = 5_000; use super::{ - command::{Parsed, parse}, + command::{Parsed, known_token, parse}, editor::Editor, plan::{PlanNode, parse as parse_plan}, wrap::LinkHit, @@ -34,10 +39,26 @@ use super::{ pub enum Update { /// The actual dynamically allocated A2A listen address. A2aAddress(String), - /// A chunk of agent prose. - Text(String), - /// A chunk of agent reasoning. - Thought(String), + /// A steer was accepted but has not been delivered into the transcript yet. + SteerAccepted { id: String, text: String }, + /// A user message delivered or replayed by the agent. + UserMessage { + id: String, + text: String, + append: bool, + }, + /// Agent prose, either appended as a chunk or replaced by an upsert. + AgentMessage { + id: String, + text: String, + append: bool, + }, + /// Agent reasoning, either appended as a chunk or replaced by an upsert. + AgentThought { + id: String, + text: String, + append: bool, + }, /// A tool call was announced. ToolStarted { id: String, @@ -47,6 +68,7 @@ pub enum Update { backgrounded: bool, }, /// A tool call changed status or produced output. + #[cfg(test)] ToolUpdated { id: String, status: Option, @@ -54,26 +76,59 @@ pub enum Update { output: Vec, backgrounded: bool, }, + /// A patchable ACP v2 tool call update or content chunk. + ToolPatched { + id: String, + title: Option, + kind: Option, + status: Option, + script: Option, + output: Option>, + append_output: bool, + backgrounded: bool, + }, /// Agent-advertised slash commands for one session. AvailableCommands { session_id: String, commands: Vec, }, + /// Full session configuration snapshot. + ConfigOptions(Vec), /// Context window accounting. Usage { used: u64, size: u64 }, + /// Standard ACP v2 foreground state. + State { + active: bool, + steerable: bool, + cancelled: bool, + }, + /// An ACP v2 turn became idle with its exact terminal reason. + Stopped(Option), /// A nested tool call started or finished inside a compose run. Runtime(RuntimeEvent), /// A diagnostic line from the agent process. Log(String), - /// An autonomous turn started after a background result arrived. - AutonomousTurnStarted(u64), - /// An autonomous turn ended. - AutonomousTurnEnded { id: u64, error: Option }, - /// A specific submitted turn ended. `None` identifies a process-wide failure. - TurnEnded { - id: Option, - error: Option, - }, + /// The ACP process exited while work could still be active. + ProcessExited(String), +} + +#[cfg(test)] +impl Update { + pub(super) fn test_text(text: String) -> Self { + Self::AgentMessage { + id: "test-agent".into(), + text, + append: true, + } + } + + pub(super) fn test_thought(text: String) -> Self { + Self::AgentThought { + id: "test-thought".into(), + text, + append: true, + } + } } /// Latest provider-reported occupancy of the main model's context window. @@ -141,11 +196,22 @@ pub struct SubmittedPrompt { pub attachments: Vec, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct PendingSteer { + pub id: String, + pub text: String, +} + pub enum Action { None, Redraw, - Submit(SubmittedPrompt), + Submit { + prompt: SubmittedPrompt, + inject: bool, + }, New(Option), + Resume(String), + Close, SelectModel { choice: ModelChoice, save_defaults: bool, @@ -303,9 +369,17 @@ pub(super) struct CachedTranscriptBlock { pub enum Phase { Idle, Working, + Blocked, Cancelling, } +#[derive(Clone, Copy)] +enum MessageRole { + User, + Agent, + Thought, +} + pub struct App { pub root: PathBuf, pub provider: String, @@ -318,6 +392,8 @@ pub struct App { pub available_commands: Vec, pub a2a: String, pub session_id: Option, + /// Session currently associated with the ordered runtime side channel. + runtime_session_id: Option, pub blocks: Vec, pub(super) transcript_cache: Vec>, pub(super) transcript_revisions: Vec, @@ -333,9 +409,9 @@ pub struct App { next_attachment: usize, pub phase: Phase, pub turn_started: Option, - next_turn_id: u64, - active_turn_id: Option, - active_autonomous_turn_id: Option, + pub can_steer: bool, + pub(super) pending_steers: VecDeque, + message_blocks: HashMap, /// The previous assistant stream ended; the next text starts a new block. agent_stream_sealed: bool, /// Exact source bytes in the latest assistant stream, before TUI rendering. @@ -507,6 +583,7 @@ fn model_score(choice: &ModelChoice, query: &str) -> Option { ordered_token_score(&all_tokens, &query_tokens).map(|score| ModelScore { tier: 4, ..score }) } +#[cfg(test)] fn media_label(media: &agentkit_core::MediaPart, index: usize) -> String { let kind = match media.modality { Modality::Image => "Image", @@ -520,11 +597,13 @@ fn media_label(media: &agentkit_core::MediaPart, index: usize) -> String { } } +#[cfg(test)] fn safe_media_uri(uri: &str) -> bool { uri.len() <= 2_048 && url::Url::parse(uri).is_ok_and(|uri| matches!(uri.scheme(), "file" | "http" | "https")) } +#[cfg(test)] fn persisted_output(output: &ToolOutput) -> Vec { let text = match output { ToolOutput::Text(text) => text.clone(), @@ -576,6 +655,7 @@ impl App { available_commands: Vec::new(), a2a, session_id: None, + runtime_session_id: None, blocks: Vec::new(), transcript_cache: Vec::new(), transcript_revisions: Vec::new(), @@ -591,9 +671,9 @@ impl App { next_attachment: 0, phase: Phase::Idle, turn_started: None, - next_turn_id: 0, - active_turn_id: None, - active_autonomous_turn_id: None, + can_steer: false, + pending_steers: VecDeque::new(), + message_blocks: HashMap::new(), agent_stream_sealed: false, latest_agent_source: String::new(), compacting: false, @@ -783,6 +863,7 @@ impl App { } /// Rebuilds the visible history from the same Items preloaded into the model. + #[cfg(test)] pub fn restore_transcript(&mut self, session_id: String, transcript: &[Item]) { self.session_id = Some(session_id); for item in transcript { @@ -950,6 +1031,144 @@ impl App { self.toast = Some((text.into(), Instant::now())); } + fn apply_message(&mut self, id: String, text: String, append: bool, role: MessageRole) { + if let Some(&index) = self.message_blocks.get(&id) { + let mut changed = false; + match (&mut self.blocks[index], role) { + (Block::User(existing), MessageRole::User) => { + if append { + existing.push_str(&text); + } else { + *existing = text.clone(); + } + changed = true; + } + (Block::Agent(existing), MessageRole::Agent) => { + if append { + existing.push_str(&text); + self.latest_agent_source.push_str(&text); + } else { + if self.latest_agent_source.ends_with(existing.as_str()) { + self.latest_agent_source + .truncate(self.latest_agent_source.len() - existing.len()); + self.latest_agent_source.push_str(&text); + } else { + self.latest_agent_source = text.clone(); + } + *existing = text.clone(); + } + changed = true; + } + (Block::Thought { text: existing, .. }, MessageRole::Thought) => { + if append { + existing.push_str(&text); + } else { + *existing = text.clone(); + } + changed = true; + } + _ => {} + } + if changed { + self.mark_block_dirty(index); + return; + } + } + + match role { + MessageRole::User => { + self.close_thought(); + self.agent_stream_sealed = true; + self.push_block(Block::User(text)); + } + MessageRole::Agent => { + self.close_thought(); + if self.agent_stream_sealed { + self.latest_agent_source = text.clone(); + } else { + self.latest_agent_source.push_str(&text); + } + self.agent_stream_sealed = false; + self.push_block(Block::Agent(text)); + } + MessageRole::Thought => self.push_block(Block::Thought { + text, + started: Instant::now(), + millis: None, + }), + } + self.message_blocks.insert(id, self.blocks.len() - 1); + } + + fn finish_turn(&mut self, cancelled: bool) { + self.finish_turn_with_outcome(!cancelled, cancelled.then_some("turn interrupted".into())); + } + + fn finish_with_stop_reason(&mut self, reason: Option) { + let (successful, notice) = match reason { + Some(StopReason::EndTurn) => (true, None), + Some(StopReason::Cancelled) => (false, Some("turn interrupted".into())), + Some(StopReason::MaxTokens) => ( + false, + Some("turn stopped: maximum token limit reached".into()), + ), + Some(StopReason::MaxTurnRequests) => ( + false, + Some("turn stopped: maximum turn-request limit reached".into()), + ), + Some(StopReason::Refusal) => (false, Some("turn refused".into())), + Some(StopReason::Other(reason)) if reason == "_error" => { + (false, Some("turn failed".into())) + } + Some(StopReason::Other(reason)) => (false, Some(format!("turn stopped: {reason}"))), + Some(_) => (false, Some("turn stopped for an unknown reason".into())), + None => (false, Some("turn stopped without a reason".into())), + }; + self.finish_turn_with_outcome(successful, notice); + } + + fn finish_turn_with_outcome(&mut self, successful: bool, notice: Option) { + self.pending_steers.clear(); + if self.phase == Phase::Idle { + self.agent_stream_sealed = true; + return; + } + self.close_thought(); + self.agent_stream_sealed = true; + let interrupted = self.phase == Phase::Cancelling; + let turn_millis = self.stop_turn_timer(); + self.phase = Phase::Idle; + self.compacting = false; + let mut finished = Vec::new(); + for (index, block) in self.blocks.iter_mut().enumerate() { + if let Block::Tool(call) = block + && call.running() + && !call.backgrounded + { + call.status = if successful { + ToolCallStatus::Completed + } else { + ToolCallStatus::Failed + }; + call.finished = Some(Instant::now()); + call.finish_running_children(); + finished.push(index); + } + } + for index in finished { + self.mark_block_dirty(index); + self.reclassify_dynamic(index); + } + if interrupted { + self.note("turn interrupted"); + } else if let Some(notice) = notice { + self.note(notice); + } + if let Some(millis) = turn_millis { + self.push_block(Block::TurnDuration(millis)); + } + } + pub fn apply(&mut self, update: Update) { match update { Update::A2aAddress(address) => self.a2a = address, @@ -961,39 +1180,30 @@ impl App { self.available_commands = commands; } } - Update::Text(text) => { - self.close_thought(); - if self.agent_stream_sealed { - self.latest_agent_source.clear(); - self.latest_agent_source.push_str(&text); - self.push_block(Block::Agent(text)); - self.agent_stream_sealed = false; + Update::SteerAccepted { id, text } => { + if self.message_blocks.contains_key(&id) { + return; + } + if let Some(pending) = self + .pending_steers + .iter_mut() + .find(|pending| pending.id == id) + { + pending.text = text; } else { - self.latest_agent_source.push_str(&text); - match self.blocks.last_mut() { - Some(Block::Agent(existing)) => { - existing.push_str(&text); - self.mark_block_dirty(self.blocks.len() - 1); - } - _ => self.push_block(Block::Agent(text)), - } + self.pending_steers.push_back(PendingSteer { id, text }); } } - Update::Thought(text) => match self.blocks.last_mut() { - Some(Block::Thought { - text: existing, - millis: None, - .. - }) => { - existing.push_str(&text); - self.mark_block_dirty(self.blocks.len() - 1); - } - _ => self.push_block(Block::Thought { - text, - started: Instant::now(), - millis: None, - }), - }, + Update::UserMessage { id, text, append } => { + self.pending_steers.retain(|pending| pending.id != id); + self.apply_message(id, text, append, MessageRole::User); + } + Update::AgentMessage { id, text, append } => { + self.apply_message(id, text, append, MessageRole::Agent); + } + Update::AgentThought { id, text, append } => { + self.apply_message(id, text, append, MessageRole::Thought); + } Update::ToolStarted { id, title, @@ -1017,6 +1227,7 @@ impl App { backgrounded, })); } + #[cfg(test)] Update::ToolUpdated { id, status, @@ -1053,82 +1264,97 @@ impl App { self.reclassify_dynamic(index); } } - Update::Usage { used, size } => { - self.usage = Some(ContextUsage { used, size }); - } - Update::Runtime(event) => self.apply_runtime(event), - Update::Log(line) => { - self.logs.push(line); - if self.logs.len() > 500 { - self.logs.drain(..self.logs.len() - 500); - } - } - Update::AutonomousTurnStarted(id) => { - if self.active_turn_id.is_none() { - self.active_autonomous_turn_id = Some(id); - self.agent_stream_sealed = true; - self.phase = Phase::Working; - self.turn_started = Some(Instant::now()); - self.follow = true; - self.scroll = usize::MAX; + Update::ToolPatched { + id, + title, + kind, + status, + script, + output, + append_output, + backgrounded, + } => { + if self.call_index(&id).is_none() { + self.apply(Update::ToolStarted { + id: id.clone(), + title: title.clone().unwrap_or_else(|| "Tool".into()), + kind: kind.clone().unwrap_or_default(), + script: script.clone(), + backgrounded, + }); } - } - Update::AutonomousTurnEnded { id, error } => { - if self.active_autonomous_turn_id != Some(id) || self.active_turn_id.is_some() { + let Some(call) = self.call_mut(&id) else { return; + }; + if let Some(title) = title { + call.title = title; } - self.close_thought(); - self.agent_stream_sealed = true; - let interrupted = self.phase == Phase::Cancelling; - let turn_millis = self.stop_turn_timer(); - self.phase = Phase::Idle; - self.active_autonomous_turn_id = None; - match (interrupted, error) { - (true, _) => self.note("turn interrupted"), - (false, Some(error)) => self.push_block(Block::Error(error)), - (false, None) => {} + if let Some(kind) = kind { + call.kind = kind; } - if let Some(millis) = turn_millis { - self.push_block(Block::TurnDuration(millis)); + if let Some(script) = script { + call.plan = parse_plan(&script); } - } - Update::TurnEnded { id, error } => { - if id.is_some() && id != self.active_turn_id { - return; + if let Some(output) = output { + if append_output { + call.output.extend(output); + } else { + call.output = output; + } + call.output.truncate(MAX_TOOL_OUTPUT_LINES); } - self.close_thought(); - self.agent_stream_sealed = true; - let interrupted = self.phase == Phase::Cancelling; - let turn_millis = self.stop_turn_timer(); - self.phase = Phase::Idle; - self.active_turn_id = None; - self.active_autonomous_turn_id = None; - self.compacting = false; - let mut finished = Vec::new(); - for (index, block) in self.blocks.iter_mut().enumerate() { - if let Block::Tool(call) = block - && call.running() - && !call.backgrounded - { - call.status = ToolCallStatus::Completed; + call.backgrounded |= backgrounded; + if let Some(status) = status { + call.status = status; + if !call.running() { call.finished = Some(Instant::now()); call.finish_running_children(); - finished.push(index); } } - for index in finished { + if let Some(index) = self.call_index(&id) { self.mark_block_dirty(index); self.reclassify_dynamic(index); } - match (interrupted, error) { - (true, _) => self.note("turn interrupted"), - (false, Some(error)) => self.push_block(Block::Error(error)), - (false, None) => {} + } + Update::Usage { used, size } => { + self.usage = Some(ContextUsage { used, size }); + } + Update::Runtime(event) => self.apply_runtime(event), + Update::Log(line) => { + self.logs.push(line); + if self.logs.len() > 500 { + self.logs.drain(..self.logs.len() - 500); } - if let Some(millis) = turn_millis { - self.push_block(Block::TurnDuration(millis)); + } + Update::ConfigOptions(_) => {} + Update::State { + active, + steerable, + cancelled, + } => { + if active { + if self.phase == Phase::Idle { + self.agent_stream_sealed = true; + self.turn_started = Some(Instant::now()); + } + if self.phase != Phase::Cancelling { + self.phase = if steerable { + Phase::Working + } else { + Phase::Blocked + }; + } + self.follow = true; + self.scroll = usize::MAX; + } else { + self.finish_turn(cancelled); } } + Update::Stopped(reason) => self.finish_with_stop_reason(reason), + Update::ProcessExited(error) => { + self.finish_turn_with_outcome(false, None); + self.push_block(Block::Error(error)); + } } if self.follow { self.scroll = usize::MAX; @@ -1136,8 +1362,15 @@ impl App { } fn apply_runtime(&mut self, event: RuntimeEvent) { + if let RuntimeEvent::SessionStarted { session_id } = event { + self.runtime_session_id = Some(session_id); + return; + } + if self.session_id.is_some() && self.runtime_session_id != self.session_id { + return; + } let event = match event { - RuntimeEvent::SessionStarted { .. } => return, + RuntimeEvent::SessionStarted { .. } => unreachable!("handled above"), RuntimeEvent::CompactionStarted { .. } => { self.compacting = true; return; @@ -1247,8 +1480,8 @@ impl App { self.latest_agent_source.clear(); self.phase = Phase::Idle; self.turn_started = None; - self.active_turn_id = None; - self.active_autonomous_turn_id = None; + self.message_blocks.clear(); + self.pending_steers.clear(); self.compacting = false; self.usage = None; self.scroll = usize::MAX; @@ -1264,21 +1497,20 @@ impl App { self.row_code.clear(); } + #[cfg(test)] pub fn push_user(&mut self, prompt: String) -> u64 { - self.push_block(Block::User(prompt)); - self.begin_turn() - } - - fn begin_turn(&mut self) -> u64 { - self.next_turn_id = self.next_turn_id.wrapping_add(1); - self.active_turn_id = Some(self.next_turn_id); - self.active_autonomous_turn_id = None; - self.agent_stream_sealed = true; - self.phase = Phase::Working; - self.turn_started = Some(Instant::now()); - self.follow = true; - self.scroll = usize::MAX; - self.next_turn_id + let id = format!("test-user-{}", self.blocks.len()); + self.apply(Update::UserMessage { + id, + text: prompt, + append: false, + }); + self.apply(Update::State { + active: true, + steerable: true, + cancelled: false, + }); + self.blocks.len() as u64 } /// Folds a tool call's raw output open or shut. @@ -1352,6 +1584,21 @@ impl App { self.next_attachment = 0; } + pub fn restore_attachments(&mut self, attachments: Vec) { + self.next_attachment = attachments + .iter() + .filter_map(|attachment| { + attachment + .placeholder + .strip_suffix(']') + .and_then(|placeholder| placeholder.rsplit_once('#')) + .and_then(|(_, number)| number.parse().ok()) + }) + .max() + .unwrap_or(0); + self.attachments = attachments; + } + pub fn prune_attachments(&mut self) { let prompt = self.editor.text(); self.attachments @@ -1586,13 +1833,35 @@ impl App { if self.editor.is_empty() { return Action::None; } - if self.working() { - self.toast("a turn is already running — esc interrupts it"); - return Action::None; + let inject = self.working(); + if inject { + if self.phase != Phase::Working { + self.toast("the agent is waiting for required input"); + return Action::None; + } + let input = self.editor.text(); + if !matches!(parse(input), Parsed::Prompt(_)) + || known_token(input, &self.available_commands).is_some() + { + self.toast("commands are available only while idle"); + return Action::None; + } + if !self.can_steer { + self.toast("this agent does not support active steering"); + return Action::None; + } } let input = self.editor.submit(); return match parse(&input) { Parsed::New { prompt } => Action::New(prompt.map(str::to_string)), + Parsed::Resume { + session_id: Some(session_id), + } => Action::Resume(session_id.to_string()), + Parsed::Resume { session_id: None } => { + self.toast("usage: /resume "); + Action::None + } + Parsed::Close => Action::Close, Parsed::Model { query: Some(query) } => match self.closest_model(query) { Some(choice) => Action::SelectModel { choice, @@ -1642,15 +1911,18 @@ impl App { } Action::None } - Parsed::Prompt(prompt) => Action::Submit(SubmittedPrompt { - text: prompt.to_string(), - attachments: self - .attachments - .iter() - .filter(|attachment| prompt.contains(&attachment.placeholder)) - .cloned() - .collect(), - }), + Parsed::Prompt(prompt) => Action::Submit { + prompt: SubmittedPrompt { + text: prompt.to_string(), + attachments: self + .attachments + .iter() + .filter(|attachment| prompt.contains(&attachment.placeholder)) + .cloned() + .collect(), + }, + inject, + }, }; } KeyCode::Enter => self.editor.insert_char('\n'), @@ -1839,7 +2111,7 @@ mod tests { time::{Duration, Instant}, }; - use agentkit_acp::{ToolCallStatus, ToolKind}; + use agent_client_protocol::schema::v2::{StopReason, ToolCallStatus, ToolKind}; use agentkit_core::{DataRef, Item, ItemKind, MediaPart, MetadataMap, Modality, Part}; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; @@ -1914,13 +2186,42 @@ mod tests { ); } + #[test] + fn runtime_events_do_not_cross_session_transitions() { + let mut app = app(); + app.start_session("new-session".into()); + app.apply(Update::Runtime(RuntimeEvent::SessionStarted { + session_id: "old-session".into(), + })); + app.apply(Update::Runtime(RuntimeEvent::CompactionStarted { + reason: "TokenThreshold".into(), + at: 0, + })); + assert!(!app.compacting); + + app.apply(Update::Runtime(RuntimeEvent::SessionStarted { + session_id: "new-session".into(), + })); + app.apply(Update::Runtime(RuntimeEvent::CompactionStarted { + reason: "TokenThreshold".into(), + at: 0, + })); + assert!(app.compacting); + } + #[test] fn turn_end_clears_compaction_state() { let mut app = app(); app.compacting = true; - app.apply(Update::TurnEnded { - id: None, - error: None, + app.apply(Update::State { + active: true, + steerable: true, + cancelled: false, + }); + app.apply(Update::State { + active: false, + steerable: false, + cancelled: false, }); assert!(!app.compacting); } @@ -2064,45 +2365,110 @@ mod tests { fn closes_running_calls_when_the_turn_ends() { let mut app = app(); compose(&mut app, "a = shell({ command: \"ls\" })\nreturn a"); - app.apply(Update::TurnEnded { - id: None, - error: None, + app.apply(Update::State { + active: true, + steerable: true, + cancelled: false, }); - let Some(Block::Tool(call)) = app.blocks.last() else { - panic!("expected a tool block"); - }; + app.apply(Update::Stopped(Some(StopReason::EndTurn))); + let call = app + .blocks + .iter() + .rev() + .find_map(|block| match block { + Block::Tool(call) => Some(call), + _ => None, + }) + .expect("tool block"); assert_eq!(call.status, ToolCallStatus::Completed); assert!(!app.working()); } #[test] - fn stale_turn_end_cannot_finish_a_newer_turn() { + fn abnormal_idle_reasons_fail_unresolved_foreground_tools() { + for (reason, expected_notice) in [ + (StopReason::Cancelled, "turn interrupted"), + ( + StopReason::MaxTokens, + "turn stopped: maximum token limit reached", + ), + ( + StopReason::MaxTurnRequests, + "turn stopped: maximum turn-request limit reached", + ), + (StopReason::Refusal, "turn refused"), + (StopReason::Other("_error".into()), "turn failed"), + (StopReason::Other("custom".into()), "turn stopped: custom"), + ] { + let mut app = app(); + compose(&mut app, "a = shell({ command: \"ls\" })\nreturn a"); + app.apply(Update::State { + active: true, + steerable: true, + cancelled: false, + }); + app.apply(Update::Stopped(Some(reason))); + + let notice = app + .blocks + .iter() + .rev() + .find_map(|block| match block { + Block::Notice(notice) => Some(notice), + _ => None, + }) + .expect("terminal notice"); + assert_eq!(notice, expected_notice); + let call = app + .blocks + .iter() + .find_map(|block| match block { + Block::Tool(call) => Some(call), + _ => None, + }) + .expect("tool call"); + assert_eq!(call.status, ToolCallStatus::Failed); + } + } + + #[test] + fn duplicate_state_updates_are_idempotent() { let mut app = app(); - let first = app.push_user("first".into()); - app.apply(Update::TurnEnded { - id: Some(first), - error: None, + app.apply(Update::State { + active: true, + steerable: true, + cancelled: false, }); - let second = app.push_user("second".into()); - - app.apply(Update::TurnEnded { - id: Some(first), - error: None, + let started = app.turn_started; + app.apply(Update::State { + active: true, + steerable: true, + cancelled: false, }); - - assert!(app.working()); - assert_eq!(app.active_turn_id, Some(second)); + assert_eq!(app.turn_started, started); + app.apply(Update::State { + active: false, + steerable: false, + cancelled: false, + }); + app.apply(Update::State { + active: false, + steerable: false, + cancelled: false, + }); + assert!(!app.working()); } #[test] fn completed_turn_duration_is_recorded_at_the_end() { let mut app = app(); - let id = app.push_user("hello".into()); + app.push_user("hello".into()); app.turn_started = Some(Instant::now() - Duration::from_secs(65)); - app.apply(Update::TurnEnded { - id: Some(id), - error: None, + app.apply(Update::State { + active: false, + steerable: false, + cancelled: false, }); assert!(matches!( @@ -2115,13 +2481,21 @@ mod tests { fn autonomous_turn_is_visible_and_cancellable() { let mut app = app(); - app.apply(Update::AutonomousTurnStarted(7)); + app.apply(Update::State { + active: true, + steerable: true, + cancelled: false, + }); assert!(app.working()); assert!(matches!(app.request_cancel(), Action::Cancel)); assert!(app.phase == Phase::Cancelling); - app.apply(Update::AutonomousTurnEnded { id: 7, error: None }); + app.apply(Update::State { + active: false, + steerable: false, + cancelled: false, + }); assert!(!app.working()); assert!(matches!( @@ -2148,9 +2522,10 @@ mod tests { backgrounded: true, }); - app.apply(Update::TurnEnded { - id: None, - error: None, + app.apply(Update::State { + active: false, + steerable: false, + cancelled: false, }); let running = app @@ -2259,13 +2634,43 @@ mod tests { app.paste("describe without it"); app.last_key = Some(Instant::now() - Duration::from_millis(500)); - let Action::Submit(prompt) = app.handle_key(press(KeyCode::Enter)) else { + let Action::Submit { prompt, .. } = app.handle_key(press(KeyCode::Enter)) else { panic!("expected the prompt to be sent"); }; assert!(prompt.attachments.is_empty()); } + #[test] + fn rejected_prompt_restores_unique_attachment_numbering() { + let mut app = app(); + for name in ["one.png", "two.png"] { + app.attach( + PathBuf::from(format!("/tmp/{name}")), + "image/png", + AttachmentKind::Image, + 3, + ); + } + let rejected = std::mem::take(&mut app.attachments); + app.clear_attachments(); + app.restore_attachments(rejected); + app.attach( + PathBuf::from("/tmp/three.png"), + "image/png", + AttachmentKind::Image, + 3, + ); + + assert_eq!( + app.attachments + .iter() + .map(|attachment| attachment.placeholder.as_str()) + .collect::>(), + ["[Image #1]", "[Image #2]", "[Image #3]"] + ); + } + #[test] fn available_command_updates_replace_the_session_set_and_clear_on_switch() { let mut app = app(); @@ -2320,7 +2725,7 @@ mod tests { let mut app = app(); app.paste("first line"); app.last_key = Some(Instant::now() - Duration::from_millis(500)); - let Action::Submit(prompt) = app.handle_key(press(KeyCode::Enter)) else { + let Action::Submit { prompt, .. } = app.handle_key(press(KeyCode::Enter)) else { panic!("expected the prompt to be sent"); }; assert_eq!(prompt.text, "first line"); @@ -2332,7 +2737,7 @@ mod tests { app.available_commands = vec!["compact".into()]; app.paste("/compact continue with this"); app.last_key = Some(Instant::now() - Duration::from_millis(500)); - let Action::Submit(prompt) = app.handle_key(press(KeyCode::Enter)) else { + let Action::Submit { prompt, .. } = app.handle_key(press(KeyCode::Enter)) else { panic!("expected an ordinary prompt"); }; assert_eq!(prompt.text, "/compact continue with this"); @@ -2366,12 +2771,73 @@ mod tests { let mut app = app(); app.paste("/newer keep this"); app.last_key = Some(Instant::now() - Duration::from_millis(500)); - let Action::Submit(prompt) = app.handle_key(press(KeyCode::Enter)) else { + let Action::Submit { prompt, .. } = app.handle_key(press(KeyCode::Enter)) else { panic!("expected a model prompt"); }; assert_eq!(prompt.text, "/newer keep this"); } + #[test] + fn active_plain_text_is_submitted_as_steering_when_advertised() { + let mut app = app(); + app.can_steer = true; + app.apply(Update::State { + active: true, + steerable: true, + cancelled: false, + }); + app.paste("change direction"); + app.last_key = Some(Instant::now() - Duration::from_millis(500)); + + let Action::Submit { prompt, inject } = app.handle_key(press(KeyCode::Enter)) else { + panic!("expected steering submission"); + }; + assert!(inject); + assert_eq!(prompt.text, "change direction"); + assert!( + !app.blocks + .iter() + .any(|block| matches!(block, Block::User(_))) + ); + + app.apply(Update::SteerAccepted { + id: "injected-1".into(), + text: prompt.text, + }); + assert_eq!(app.pending_steers.len(), 1); + assert!( + !app.blocks + .iter() + .any(|block| matches!(block, Block::User(_))) + ); + + app.apply(Update::UserMessage { + id: "injected-1".into(), + text: "change direction".into(), + append: false, + }); + assert!(app.pending_steers.is_empty()); + assert!(matches!(app.blocks.last(), Some(Block::User(text)) if text == "change direction")); + assert!(app.working()); + } + + #[test] + fn active_text_is_preserved_when_steering_is_not_advertised() { + let mut app = app(); + app.apply(Update::State { + active: true, + steerable: true, + cancelled: false, + }); + app.paste("wait"); + app.last_key = Some(Instant::now() - Duration::from_millis(500)); + assert!(matches!( + app.handle_key(press(KeyCode::Enter)), + Action::None + )); + assert_eq!(app.editor.text(), "wait"); + } + #[test] fn new_command_waits_for_the_active_turn_to_be_idle() { let mut app = app(); @@ -2461,9 +2927,13 @@ mod tests { fn copies_latest_agent_source_across_tool_boundaries() { let mut app = app(); app.push_user("first".into()); - app.apply(Update::Text("# Heading\n\tindented ".into())); + app.apply(Update::test_text("# Heading\n\tindented ".into())); compose(&mut app, "value = tool({})"); - app.apply(Update::Text("\n\n- item".into())); + app.apply(Update::AgentMessage { + id: "post-tool-agent".into(), + text: "\n\n- item".into(), + append: true, + }); let action = app.handle_key(modified_press(KeyCode::Char('y'), KeyModifiers::CONTROL)); let Action::Copy(text) = action else { @@ -2475,13 +2945,18 @@ mod tests { #[test] fn copies_only_agent_text_after_the_latest_user_message() { let mut app = app(); - app.apply(Update::Text("old".into())); - app.apply(Update::TurnEnded { - id: None, - error: None, + app.apply(Update::test_text("old".into())); + app.apply(Update::State { + active: false, + steerable: false, + cancelled: false, }); app.push_user("next".into()); - app.apply(Update::Text("new".into())); + app.apply(Update::AgentMessage { + id: "next-agent".into(), + text: "new".into(), + append: true, + }); assert_eq!(app.latest_agent_text().as_deref(), Some("new")); } @@ -2489,8 +2964,8 @@ mod tests { #[test] fn streams_agent_text_into_one_block() { let mut app = app(); - app.apply(Update::Text("he".into())); - app.apply(Update::Text("llo".into())); + app.apply(Update::test_text("he".into())); + app.apply(Update::test_text("llo".into())); assert_eq!(app.blocks.len(), 1); let Some(Block::Agent(text)) = app.blocks.last() else { panic!("expected an agent block"); @@ -2501,13 +2976,22 @@ mod tests { #[test] fn autonomous_text_starts_a_new_block_after_turn_end() { let mut app = app(); - app.apply(Update::Text("Started.".into())); - app.apply(Update::TurnEnded { - id: None, - error: None, + app.apply(Update::test_text("Started.".into())); + app.apply(Update::State { + active: false, + steerable: false, + cancelled: false, + }); + app.apply(Update::AgentMessage { + id: "autonomous".into(), + text: "RAVENS_".into(), + append: true, + }); + app.apply(Update::AgentMessage { + id: "autonomous".into(), + text: "HARBOR_INEVITABLE".into(), + append: true, }); - app.apply(Update::Text("RAVENS_".into())); - app.apply(Update::Text("HARBOR_INEVITABLE".into())); let agents = app .blocks @@ -2534,7 +3018,7 @@ mod tests { script: None, backgrounded: true, }); - app.apply(Update::Text("first completion".into())); + app.apply(Update::test_text("first completion".into())); app.apply(Update::ToolUpdated { id: "background".into(), status: Some(ToolCallStatus::Completed), @@ -2542,8 +3026,16 @@ mod tests { output: Vec::new(), backgrounded: false, }); - app.apply(Update::Text("second completion".into())); - app.apply(Update::Text(" continued".into())); + app.apply(Update::AgentMessage { + id: "second".into(), + text: "second completion".into(), + append: true, + }); + app.apply(Update::AgentMessage { + id: "second".into(), + text: " continued".into(), + append: true, + }); let agents = app .blocks diff --git a/src/tui/command.rs b/src/tui/command.rs index b56db1e..b7fda6a 100644 --- a/src/tui/command.rs +++ b/src/tui/command.rs @@ -8,6 +8,8 @@ use std::ops::Range; #[derive(Clone, Copy)] enum Kind { New, + Resume, + Close, Model, Effort, } @@ -24,6 +26,14 @@ const LOCAL_COMMANDS: &[Spec] = &[ token: "/new", kind: Kind::New, }, + Spec { + token: "/resume", + kind: Kind::Resume, + }, + Spec { + token: "/close", + kind: Kind::Close, + }, Spec { token: "/model", kind: Kind::Model, @@ -37,6 +47,8 @@ const LOCAL_COMMANDS: &[Spec] = &[ #[derive(Debug, PartialEq, Eq)] pub enum Parsed<'a> { New { prompt: Option<&'a str> }, + Resume { session_id: Option<&'a str> }, + Close, Model { query: Option<&'a str> }, Effort { value: Option<&'a str> }, Prompt(&'a str), @@ -66,6 +78,8 @@ pub fn parse(input: &str) -> Parsed<'_> { let prompt = (!remainder.is_empty()).then_some(remainder); match spec.kind { Kind::New => Parsed::New { prompt }, + Kind::Resume => Parsed::Resume { session_id: prompt }, + Kind::Close => Parsed::Close, Kind::Model => Parsed::Model { query: prompt }, Kind::Effort => Parsed::Effort { value: prompt }, } @@ -91,6 +105,13 @@ mod tests { #[test] fn parses_commands_with_or_without_a_following_prompt() { assert_eq!(parse("/new"), Parsed::New { prompt: None }); + assert_eq!( + parse("/resume session-1"), + Parsed::Resume { + session_id: Some("session-1") + } + ); + assert_eq!(parse("/close"), Parsed::Close); assert_eq!(parse("/model"), Parsed::Model { query: None }); assert_eq!(parse("/effort"), Parsed::Effort { value: None }); assert_eq!( diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 7ad94f9..f14b829 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -25,11 +25,9 @@ use std::{ time::Duration, }; -use agent_client_protocol::{ByteStreams, schema::ProtocolVersion}; -use agentkit_acp::{ - CancelNotification, CloseSessionRequest, ContentBlock, SessionConfigKind, SessionConfigOption, - SessionConfigSelectOptions, SessionNotification, SessionUpdate, SetSessionConfigOptionRequest, - ToolCallContent, +use agent_client_protocol::{ + ByteStreams, + schema::{MaybeUndefined, ProtocolVersion, v2 as wire}, }; use base64::{Engine as _, engine::general_purpose::STANDARD}; use crossterm::{ @@ -43,16 +41,21 @@ use crossterm::{ }; use futures_util::StreamExt; use ratatui::DefaultTerminal; +use serde::{Deserialize, Serialize}; use serde_json::Value; use tokio::{ io::{AsyncBufReadExt, BufReader}, sync::{mpsc, oneshot}, }; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; +use wire::{ + CancelSessionNotification, CloseSessionRequest, ContentBlock, SessionConfigKind, + SessionConfigOption, SessionConfigSelectOptions, SessionUpdate, SetSessionConfigOptionRequest, + ToolCallContent, UpdateSessionNotification, +}; use crate::{ events::{self, EVENTS_ENV}, - protocols::acp::{CancelBackgroundRequest, DetachComposeRequest, TurnStateNotification}, tools::mcp::CredentialStorage, }; @@ -69,16 +72,39 @@ const MAX_ATTACHMENT_BYTES: u64 = 10 * 1024 * 1024; const MAX_TOTAL_ATTACHMENT_BYTES: u64 = 20 * 1024 * 1024; /// Output lines kept per tool call; the fold shows the count either way. const MAX_OUTPUT_LINES: usize = 5_000; + +#[derive(Debug, Clone, Serialize, Deserialize, agent_client_protocol::JsonRpcRequest)] +#[request(method = "kit/background/cancel", response = CancelBackgroundResponse)] +struct CancelBackgroundRequest { + session_id: wire::SessionId, + call_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, agent_client_protocol::JsonRpcResponse)] +struct CancelBackgroundResponse { + cancelled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, agent_client_protocol::JsonRpcRequest)] +#[request(method = "kit/compose/detach", response = DetachComposeResponse)] +struct DetachComposeRequest { + session_id: wire::SessionId, + call_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, agent_client_protocol::JsonRpcResponse)] +struct DetachComposeResponse { + detached: bool, +} /// How long the agent gets to answer the ACP handshake before the client gives /// up. Nothing in it waits on a model, so a slow answer means a wedged agent. const HANDSHAKE: Duration = Duration::from_secs(30); +/// Grace for an ACP session to close before the backend is terminated. +const CLOSE_SESSION: Duration = Duration::from_secs(3); /// Grace for the agent's last diagnostics to arrive once it has exited. const LAST_WORDS: Duration = Duration::from_millis(250); /// Diagnostic lines quoted back when the agent dies during the handshake. const FAILURE_LINES: usize = 5; -/// How long a turn interrupted on the way out gets to finish unwinding, so the -/// transcript it owns is closed out before the agent is killed. -const SETTLE: Duration = Duration::from_secs(3); #[cfg(unix)] fn detach_from_controlling_terminal(command: &mut tokio::process::Command) { @@ -106,7 +132,7 @@ fn current_model_choice(options: Option<&[SessionConfigOption]>) -> Option Some(select.current_value.to_string()), _ => None, @@ -122,7 +148,7 @@ fn effort_state(options: Option<&[SessionConfigOption]>) -> Option<(String, Vec< .. } = options? .iter() - .find(|option| option.id.to_string() == "reasoning_effort")? + .find(|option| option.config_id.to_string() == "reasoning_effort")? else { return None; }; @@ -160,7 +186,7 @@ fn model_choices(options: Option<&[SessionConfigOption]>) -> Vec { }) = options .unwrap_or_default() .iter() - .find(|option| option.id.to_string() == "model") + .find(|option| option.config_id.to_string() == "model") else { return Vec::new(); }; @@ -170,7 +196,7 @@ fn model_choices(options: Option<&[SessionConfigOption]>) -> Vec { groups .iter() .flat_map(|group| { - let provider = group.group.to_string(); + let provider = group.group_id.to_string(); group.options.iter().map(move |option| { let id = option.value.to_string(); let model = id @@ -263,8 +289,9 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( let root = &root .canonicalize() .map_err(|error| Failure(format!("{}: {error}", root.display())))?; - let persisted_session_id = resume - .map(str::to_string) + let resume_session_id = resume.map(str::to_string); + let persisted_session_id = resume_session_id + .clone() .unwrap_or_else(crate::session::new_id); let active_persisted_id = Arc::new(Mutex::new(persisted_session_id.clone())); let mut command = crate::acp_child::serve_command( @@ -274,7 +301,7 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( reasoning_effort, openrouter_api_key, &persisted_session_id, - resume.is_some(), + resume_session_id.is_some(), )?; if let Some(address) = a2a { command.arg("--a2a").arg(address); @@ -337,10 +364,9 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( } // Stderr closing means the agent process is gone; stop any spinner // waiting on a turn that can no longer finish. - let _ = diagnostics.send(Update::TurnEnded { - id: None, - error: Some("the agent process exited — press ctrl+c to leave".into()), - }); + let _ = diagnostics.send(Update::ProcessExited( + "the agent process exited — press ctrl+c to leave".into(), + )); }); // The child is watched from its own task, which also owns it: aborting that @@ -363,49 +389,64 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( let notifications = updates_tx.clone(); let cleanup_root = root.clone(); let transition_session = Arc::clone(&active_persisted_id); + let notification_session = Arc::clone(&active_persisted_id); let result = agent_client_protocol::Client - .builder() + .v2() .on_receive_notification( - async move |notification: SessionNotification, _cx| { - for update in translate(notification) { + async move |notification: UpdateSessionNotification, _cx| { + let current = notification_session.lock().ok().map(|id| id.clone()); + for update in current + .as_deref() + .map_or_else(Vec::new, |current| translate_for_session(notification, current)) + { let _ = notifications.send(update); } Ok(()) }, agent_client_protocol::on_receive_notification!(), ) - .on_receive_notification( - { - let turn_states = updates_tx.clone(); - async move |notification: TurnStateNotification, _cx| { - let update = if notification.active { - Update::AutonomousTurnStarted(notification.turn_id) - } else { - Update::AutonomousTurnEnded { - id: notification.turn_id, - error: notification.error, - } - }; - let _ = turn_states.send(update); - Ok(()) - } - }, - agent_client_protocol::on_receive_notification!(), - ) .connect_with(transport, async move |connection| { // An agent that dies here — a taken A2A port, a bad root, no // credentials — leaves its half of the handshake unanswered, and // waiting on it forever shows the user nothing at all. Its exit and // its silence both end the wait with something to read. let handshake = async { - connection - .send_request(agentkit_acp::InitializeRequest::new(ProtocolVersion::V1)) + let initialized = connection + .send_request(wire::InitializeRequest::new( + ProtocolVersion::V2, + wire::Implementation::new("kit-tui", env!("CARGO_PKG_VERSION")), + )) .block_task() .await?; - connection - .send_request(agentkit_acp::NewSessionRequest::new(root.clone())) - .block_task() - .await + if initialized.protocol_version != ProtocolVersion::V2 { + return Err(agent_client_protocol::Error::into_internal_error( + std::io::Error::other("the agent did not negotiate ACP v2"), + )); + } + let can_steer = initialized.capabilities.session.as_ref() + .and_then(|session| session.inject.as_ref()) + .is_some_and(|inject| { + inject.modes.contains(&wire::SessionInjectMode::Steer) + && inject.steer_in_stream.as_ref().is_some_and(|modes| { + modes.contains(&wire::SessionInjectSteerInStream::Finish) + }) + }); + if let Some(resume_id) = resume_session_id.clone() { + let response = connection + .send_request( + wire::ResumeSessionRequest::new(resume_id.clone(), root.clone()) + .replay_from(wire::ReplayFrom::Start(wire::ReplayFromStart::new())), + ) + .block_task() + .await?; + Ok((wire::SessionId::new(resume_id), response.config_options, can_steer)) + } else { + let response = connection + .send_request(wire::NewSessionRequest::new(root.clone())) + .block_task() + .await?; + Ok((response.session_id, response.config_options, can_steer)) + } }; let session = tokio::select! { session = handshake => session?, @@ -423,16 +464,14 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( )); } }; - let mut session_id = session.session_id.clone(); + let (mut session_id, config_options, can_steer) = session; let active_session_id = durable_session_id(&session_id).map_err(|error| { agent_client_protocol::Error::into_internal_error(std::io::Error::other(error)) })?; - // The server has acquired the mutation lock during NewSession, so - // this read is the exact stable snapshot it preloaded into the model. - let restored = crate::session::load(&root, &active_session_id).map_err(|error| { - agent_client_protocol::Error::into_internal_error(std::io::Error::other(error)) - })?; - + // Install every fallible signal handler before changing terminal modes so + // an installation failure cannot leave the caller's terminal altered. + let mut stop = + Stop::new().map_err(agent_client_protocol::Error::into_internal_error)?; let mut terminal = enter().map_err(agent_client_protocol::Error::into_internal_error)?; let mut app = App::new( @@ -441,18 +480,15 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( model, a2a, ); - refresh_config_state(&mut app, session.config_options.as_deref()); + refresh_config_state(&mut app, Some(&config_options)); + app.can_steer = can_steer; if let Ok(mut active) = transition_session.lock() { *active = active_session_id.clone(); } - app.restore_transcript(active_session_id, &restored); + app.start_session(active_session_id); let mut events = EventStream::new(); let mut ticker = tokio::time::interval(TICK); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - // The turn in flight, if any: leaving is not allowed to abandon it. - let mut turn: Option> = None; - let mut stop = - Stop::new().map_err(agent_client_protocol::Error::into_internal_error)?; let result: Result<(), agent_client_protocol::Error> = async { loop { terminal @@ -488,89 +524,151 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( } match action { Action::Quit => return Ok(()), - Action::Submit(prompt) => { + Action::Submit { prompt, inject } => { let blocks = match prompt_blocks(&prompt) { Ok(blocks) => blocks, Err(error) => { + app.paste(&prompt.text); + app.restore_attachments(prompt.attachments); app.note(error); continue; } }; - let turn_id = app.push_user(prompt.text); app.clear_attachments(); - let connection = connection.clone(); - let session = session_id.clone(); - let updates = updates_tx.clone(); - turn = Some(tokio::spawn(async move { - let outcome = connection - .send_request(agentkit_acp::PromptRequest::new( - session, blocks, + let outcome = if inject { + connection + .send_request(wire::InjectSessionRequest::new( + session_id.clone(), + wire::SessionInjectMode::Steer, + blocks, )) .block_task() - .await; - let _ = updates.send(Update::TurnEnded { - id: Some(turn_id), - error: match outcome { - Ok(_) => None, - Err(error) => Some(error.to_string()), - }, - }); - })); + .await + .map(|response| Some(response.message_id)) + } else { + connection + .send_request(wire::PromptRequest::new(session_id.clone(), blocks)) + .block_task() + .await + .map(|_| None) + }; + match outcome { + Ok(Some(message_id)) => app.apply(Update::SteerAccepted { + id: message_id.to_string(), + text: prompt.text, + }), + Ok(None) => {} + Err(error) => { + app.paste(&prompt.text); + app.restore_attachments(prompt.attachments); + app.note(format!("message was not accepted: {}", error.message)); + } + } } Action::New(first_prompt) => { - // Idle-only key handling guarantees there is no active - // turn to abandon. Await its completed task before closing - // the old ACP driver and its transcript lock. - if let Some(completed) = turn.take() { - let _ = completed.await; - } connection .send_request(CloseSessionRequest::new(session_id.clone())) .block_task() .await?; let session = connection - .send_request(agentkit_acp::NewSessionRequest::new(root.clone())) + .send_request(wire::NewSessionRequest::new(root.clone())) .block_task() .await?; session_id = session.session_id; let persisted_id = durable_session_id(&session_id).map_err(|error| { - agent_client_protocol::Error::into_internal_error( - std::io::Error::other(error), - ) + agent_client_protocol::Error::into_internal_error(std::io::Error::other(error)) })?; if let Ok(mut active) = transition_session.lock() { *active = persisted_id.clone(); } app.start_session(persisted_id); - refresh_config_state( - &mut app, - session.config_options.as_deref(), - ); + refresh_config_state(&mut app, Some(&session.config_options)); if let Some(prompt) = first_prompt { - let turn_id = app.push_user(prompt.clone()); - let connection = connection.clone(); - let session = session_id.clone(); - let updates = updates_tx.clone(); - turn = Some(tokio::spawn(async move { - let outcome = connection - .send_request(agentkit_acp::PromptRequest::new( - session, - vec![ContentBlock::Text( - agentkit_acp::TextContent::new(prompt), - )], - )) - .block_task() - .await; - let _ = updates.send(Update::TurnEnded { - id: Some(turn_id), - error: match outcome { - Ok(_) => None, - Err(error) => Some(error.to_string()), - }, - }); - })); + let outcome = connection + .send_request(wire::PromptRequest::new( + session_id.clone(), + vec![ContentBlock::Text(wire::TextContent::new(prompt))], + )) + .block_task() + .await; + if let Err(error) = outcome { + app.note(format!("message was not accepted: {}", error.message)); + } } } + Action::Resume(requested_id) => { + if let Err(error) = crate::session::validate_id(&requested_id) { + app.note(format!("invalid session id: {error}")); + continue; + } + if let Err(error) = crate::session::load(&root, &requested_id) { + app.note(format!("could not resume session: {error}")); + continue; + } + let previous_session_id = session_id.clone(); + let previous_persisted_id = durable_session_id(&session_id) + .map_err(|error| { + agent_client_protocol::Error::into_internal_error( + std::io::Error::other(error), + ) + })?; + if let Err(error) = connection + .send_request(CloseSessionRequest::new(session_id.clone())) + .block_task() + .await + { + app.note(format!( + "could not close the current session: {}", + error.message + )); + continue; + } + session_id = wire::SessionId::new(requested_id.clone()); + if let Ok(mut active) = transition_session.lock() { + *active = requested_id.clone(); + } + app.start_session(requested_id.clone()); + match request_resume(&connection, session_id.clone(), root.clone()).await { + Ok(response) => refresh_config_state( + &mut app, + Some(&response.config_options), + ), + Err(error) => { + session_id = previous_session_id; + if let Ok(mut active) = transition_session.lock() { + *active = previous_persisted_id.clone(); + } + app.start_session(previous_persisted_id); + let restored = request_resume( + &connection, + session_id.clone(), + root.clone(), + ) + .await; + match restored { + Ok(response) => { + refresh_config_state( + &mut app, + Some(&response.config_options), + ); + app.note(format!( + "could not resume {requested_id}: {}", + error.message + )); + } + Err(restore_error) => { + return Err(agent_client_protocol::Error::into_internal_error( + std::io::Error::other(format!( + "could not resume {requested_id}: {}; could not restore the previous session: {}", + error.message, restore_error.message + )), + )); + } + } + } + } + } + Action::Close => return Ok(()), Action::SelectModel { choice, save_defaults } => { let response = connection.send_request( SetSessionConfigOptionRequest::new( @@ -636,7 +734,7 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( } Action::Cancel => { let _ = connection.send_notification( - CancelNotification::new(session_id.clone()), + CancelSessionNotification::new(session_id.clone()), ); } Action::DetachCompose(call_id) => { @@ -676,8 +774,14 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( }, update = updates_rx.recv() => match update { Some(update) => { + if let Update::ConfigOptions(options) = &update { + refresh_config_state(&mut app, Some(options)); + } app.apply(update); while let Ok(update) = updates_rx.try_recv() { + if let Update::ConfigOptions(options) = &update { + refresh_config_state(&mut app, Some(options)); + } app.apply(update); } } @@ -690,24 +794,21 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( } .await; leave(terminal); - // Quitting mid-turn — by key, by signal, or because the terminal - // went away — must not abandon a running tool call. The agent is - // asked to interrupt and given a moment to record the results it - // owes, since the transcript it writes has to stay resumable even - // though the process is about to be killed. - if let Some(turn) = turn.filter(|turn| !turn.is_finished()) { - let _ = connection.send_notification(CancelNotification::new(session_id.clone())); - let _ = tokio::time::timeout(SETTLE, turn).await; - } // Closing the ACP session removes its driver from the server, // dropping the transcript observer and its filesystem lock. Merely // closing stdio does not ask the headless runtime to close sessions. - let closed = connection - .send_request(CloseSessionRequest::new(session_id)) - .block_task() - .await; + let closed = bounded_graceful_close( + connection + .send_request(CloseSessionRequest::new(session_id)) + .block_task(), + stop.requested(), + CLOSE_SESSION, + ) + .await; result?; - closed?; + if let Some(closed) = closed { + closed?; + } Ok(()) }) .await @@ -985,9 +1086,7 @@ fn prompt_blocks(prompt: &SubmittedPrompt) -> Result, String> ); } } - let mut blocks = vec![ContentBlock::Text(agentkit_acp::TextContent::new( - model_text, - ))]; + let mut blocks = vec![ContentBlock::Text(wire::TextContent::new(model_text))]; for (attachment, bytes) in media { let data = STANDARD.encode(bytes); blocks.push(match attachment.kind { @@ -995,12 +1094,10 @@ fn prompt_blocks(prompt: &SubmittedPrompt) -> Result, String> let uri = url::Url::from_file_path(&attachment.path) .ok() .map(|uri| uri.to_string()); - ContentBlock::Image( - agentkit_acp::ImageContent::new(data, attachment.mime_type).uri(uri), - ) + ContentBlock::Image(wire::ImageContent::new(data, attachment.mime_type).uri(uri)) } AttachmentKind::Audio => { - ContentBlock::Audio(agentkit_acp::AudioContent::new(data, attachment.mime_type)) + ContentBlock::Audio(wire::AudioContent::new(data, attachment.mime_type)) } }); } @@ -1061,6 +1158,8 @@ fn restore_modes() { /// arrives at the prompt as garbage. Holding the signal streams for the whole /// session and returning through the normal exit keeps that from happening. struct Stop { + #[cfg(unix)] + interrupt: tokio::signal::unix::Signal, #[cfg(unix)] terminate: tokio::signal::unix::Signal, #[cfg(unix)] @@ -1072,6 +1171,7 @@ impl Stop { fn new() -> std::io::Result { use tokio::signal::unix::{SignalKind, signal}; Ok(Self { + interrupt: signal(SignalKind::interrupt())?, terminate: signal(SignalKind::terminate())?, hangup: signal(SignalKind::hangup())?, }) @@ -1085,6 +1185,7 @@ impl Stop { #[cfg(unix)] async fn requested(&mut self) { tokio::select! { + _ = self.interrupt.recv() => {} _ = self.terminate.recv() => {} _ = self.hangup.recv() => {} } @@ -1096,69 +1197,235 @@ impl Stop { } } +async fn bounded_graceful_close( + close: impl std::future::Future, + stop: impl std::future::Future, + grace: Duration, +) -> Option { + tokio::select! { + output = close => Some(output), + () = stop => None, + () = tokio::time::sleep(grace) => None, + } +} + fn leave(terminal: DefaultTerminal) { restore_modes(); drop(terminal); ratatui::restore(); } -fn durable_session_id(session_id: &agentkit_acp::SessionId) -> Result { +async fn request_resume( + connection: &agent_client_protocol::V2ConnectionTo, + session_id: wire::SessionId, + root: PathBuf, +) -> Result { + connection + .send_request( + wire::ResumeSessionRequest::new(session_id, root) + .replay_from(wire::ReplayFrom::Start(wire::ReplayFromStart::new())), + ) + .block_task() + .await +} + +fn durable_session_id(session_id: &wire::SessionId) -> Result { let session_id = session_id.to_string(); crate::session::validate_id(&session_id)?; Ok(session_id) } /// Maps one ACP session notification onto client updates. -fn translate(notification: SessionNotification) -> Vec { +fn translate(notification: UpdateSessionNotification) -> (String, Vec) { let session_id = notification.session_id.to_string(); - match notification.update { + let updates = match notification.update { + SessionUpdate::UserMessageChunk(chunk) => message_of(chunk.content) + .map(|text| Update::UserMessage { + id: chunk.message_id.to_string(), + text, + append: true, + }) + .into_iter() + .collect(), SessionUpdate::AgentMessageChunk(chunk) => message_of(chunk.content) - .map(Update::Text) + .map(|text| Update::AgentMessage { + id: chunk.message_id.to_string(), + text, + append: true, + }) .into_iter() .collect(), SessionUpdate::AgentThoughtChunk(chunk) => text_of(chunk.content) - .map(Update::Thought) + .map(|text| Update::AgentThought { + id: chunk.message_id.to_string(), + text, + append: true, + }) .into_iter() .collect(), - SessionUpdate::ToolCall(call) => vec![Update::ToolStarted { - id: call.tool_call_id.to_string(), - title: call.title, - kind: call.kind, - script: call.raw_input.as_ref().and_then(script_of), - backgrounded: call + SessionUpdate::UserMessage(message) => message_patch( + message.message_id.to_string(), + message.content, + MessageKind::User, + ), + SessionUpdate::AgentMessage(message) => message_patch( + message.message_id.to_string(), + message.content, + MessageKind::Agent, + ), + SessionUpdate::AgentThought(message) => message_patch( + message.message_id.to_string(), + message.content, + MessageKind::Thought, + ), + SessionUpdate::ToolCallUpdate(update) => { + let output = match &update.content { + MaybeUndefined::Value(content) => Some(output_of(Some(content))), + MaybeUndefined::Null => Some(Vec::new()), + MaybeUndefined::Undefined => match &update.raw_output { + MaybeUndefined::Value(output) => Some(raw_output_lines(output)), + MaybeUndefined::Null => Some(Vec::new()), + MaybeUndefined::Undefined => None, + }, + }; + let script = match &update.raw_input { + MaybeUndefined::Value(input) => Some(script_of(input).unwrap_or_default()), + MaybeUndefined::Null => Some(String::new()), + MaybeUndefined::Undefined => None, + }; + let backgrounded = update .raw_input - .as_ref() + .value() .and_then(|input| input.get("background")) .and_then(Value::as_bool) - == Some(true), - }], - SessionUpdate::ToolCallUpdate(update) => { - let output = output_of(update.fields.content.as_deref()); + == Some(true) + || output.as_ref().is_some_and(|lines| { + lines + .iter() + .any(|line| line.contains("is now running in the background")) + }); + vec![Update::ToolPatched { + id: update.tool_call_id.to_string(), + title: match update.title { + MaybeUndefined::Value(title) => Some(title), + MaybeUndefined::Null => Some("Tool".into()), + MaybeUndefined::Undefined => None, + }, + kind: match update.kind { + MaybeUndefined::Value(kind) => Some(kind), + MaybeUndefined::Null => Some(wire::ToolKind::default()), + MaybeUndefined::Undefined => None, + }, + status: match update.status { + MaybeUndefined::Value(status) => Some(status), + MaybeUndefined::Null => Some(wire::ToolCallStatus::default()), + MaybeUndefined::Undefined => None, + }, + script, + output, + append_output: false, + backgrounded, + }] + } + SessionUpdate::ToolCallContentChunk(chunk) => { + let output = output_of(Some(std::slice::from_ref(&chunk.content))); let backgrounded = output .iter() .any(|line| line.contains("is now running in the background")); - vec![Update::ToolUpdated { - id: update.tool_call_id.to_string(), - status: update.fields.status, - script: update.fields.raw_input.as_ref().and_then(script_of), - output, + vec![Update::ToolPatched { + id: chunk.tool_call_id.to_string(), + title: None, + kind: None, + status: None, + script: None, + output: Some(output), + append_output: true, backgrounded, }] } SessionUpdate::AvailableCommandsUpdate(update) => vec![Update::AvailableCommands { - session_id, + session_id: session_id.clone(), commands: update .available_commands .into_iter() .map(|command| command.name) .collect(), }], + SessionUpdate::ConfigOptionUpdate(update) => { + vec![Update::ConfigOptions(update.config_options)] + } SessionUpdate::UsageUpdate(usage) => vec![Update::Usage { used: usage.used, size: usage.size, }], + SessionUpdate::StateUpdate(state) => match state { + wire::StateUpdate::Running(_) => vec![Update::State { + active: true, + steerable: true, + cancelled: false, + }], + wire::StateUpdate::RequiresAction(_) => vec![Update::State { + active: true, + steerable: false, + cancelled: false, + }], + wire::StateUpdate::Idle(idle) => vec![Update::Stopped(idle.stop_reason)], + _ => Vec::new(), + }, _ => Vec::new(), - } + }; + (session_id, updates) +} + +fn translate_for_session(notification: UpdateSessionNotification, current: &str) -> Vec { + let (session_id, updates) = translate(notification); + updates + .into_iter() + .filter(|update| { + session_id == current || matches!(update, Update::AvailableCommands { .. }) + }) + .collect() +} + +#[derive(Clone, Copy)] +enum MessageKind { + User, + Agent, + Thought, +} + +fn message_patch( + id: String, + content: MaybeUndefined>, + kind: MessageKind, +) -> Vec { + let blocks = match content { + MaybeUndefined::Undefined => return Vec::new(), + MaybeUndefined::Null => Vec::new(), + MaybeUndefined::Value(blocks) => blocks, + }; + let text = blocks + .into_iter() + .filter_map(message_of) + .collect::>() + .join(""); + vec![match kind { + MessageKind::User => Update::UserMessage { + id, + text, + append: false, + }, + MessageKind::Agent => Update::AgentMessage { + id, + text, + append: false, + }, + MessageKind::Thought => Update::AgentThought { + id, + text, + append: false, + }, + }] } fn text_of(content: ContentBlock) -> Option { @@ -1200,6 +1467,20 @@ fn script_of(input: &Value) -> Option { } /// A tool call's output as readable lines, kept whole for the folded card. +fn raw_output_lines(output: &Value) -> Vec { + if let Some(text) = output + .as_str() + .or_else(|| output.get("text").and_then(Value::as_str)) + { + return readable(text); + } + serde_json::to_string_pretty(output) + .unwrap_or_else(|_| output.to_string()) + .lines() + .map(str::to_string) + .collect() +} + fn output_of(content: Option<&[ToolCallContent]>) -> Vec { let mut output = Vec::new(); for entry in content.unwrap_or_default() { @@ -1210,9 +1491,11 @@ fn output_of(content: Option<&[ToolCallContent]>) -> Vec { } } ToolCallContent::Diff(diff) => output.push(format!( - "{} · {} lines", - diff.path.display(), - diff.new_text.lines().count() + "{} files · {} lines", + diff.changes.len(), + diff.patch + .as_ref() + .map_or(0, |patch| patch.text.lines().count()) )), _ => {} } @@ -1245,17 +1528,21 @@ fn readable(text: &str) -> Vec { mod tests { use std::path::PathBuf; - use agentkit_acp::{ - AvailableCommand, AvailableCommandsUpdate, ContentBlock, SessionConfigOption, - SessionConfigSelectGroup, SessionConfigSelectOption, SessionNotification, SessionUpdate, + use agent_client_protocol::schema::v2::{ + AvailableCommand, AvailableCommandsUpdate, ContentBlock, IdleStateUpdate, + RunningStateUpdate, SessionConfigOption, SessionConfigSelectGroup, + SessionConfigSelectOption, SessionUpdate, StateUpdate, TextContent, + UpdateSessionNotification, UserMessage, }; use crossterm::event::Event; + use serde_json::json; + use super::{ MAX_ATTACHMENTS, ModelChoice, attachments_from_paste, current_model_choice, detach_from_controlling_terminal, durable_session_id, effort_state, handle, message_of, osc52, prompt_blocks, readable, refresh_config_state, save_effort_default_to, - save_model_defaults_to, translate, + save_model_defaults_to, translate, translate_for_session, wire, }; use crate::tui::app::{App, SubmittedPrompt, Update}; @@ -1276,7 +1563,7 @@ mod tests { #[test] fn translates_available_commands_with_their_session() { - let updates = translate(SessionNotification::new( + let (_, updates) = translate(UpdateSessionNotification::new( "session", SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(vec![ AvailableCommand::new("compact", "Compact context"), @@ -1290,6 +1577,79 @@ mod tests { )); } + #[test] + fn translates_accepted_user_messages_and_foreground_state() { + let user = UpdateSessionNotification::new( + "session", + SessionUpdate::UserMessage( + UserMessage::new("user-1") + .content(vec![ContentBlock::Text(TextContent::new("steer"))]), + ), + ); + assert!(matches!( + translate_for_session(user, "session").as_slice(), + [Update::UserMessage { id, text, append: false }] + if id == "user-1" && text == "steer" + )); + + let running = UpdateSessionNotification::new( + "session", + SessionUpdate::StateUpdate(StateUpdate::Running(RunningStateUpdate::new())), + ); + assert!(matches!( + translate_for_session(running, "session").as_slice(), + [Update::State { + active: true, + steerable: true, + cancelled: false + }] + )); + let idle = UpdateSessionNotification::new( + "session", + SessionUpdate::StateUpdate(StateUpdate::Idle(IdleStateUpdate::new())), + ); + assert!(matches!( + translate_for_session(idle, "session").as_slice(), + [Update::Stopped(None)] + )); + } + + #[test] + fn translates_replayed_raw_tool_output() { + let update = UpdateSessionNotification::new( + "session", + SessionUpdate::ToolCallUpdate( + wire::ToolCallUpdate::new("tool-1") + .status(wire::ToolCallStatus::Completed) + .raw_output(Some(json!({ "text": "first\nsecond" }))), + ), + ); + + assert!(matches!( + translate_for_session(update, "session").as_slice(), + [Update::ToolPatched { output: Some(output), .. }] + if output == &["first", "second"] + )); + } + + #[test] + fn defers_session_scoped_commands_but_drops_other_inactive_updates() { + let commands = UpdateSessionNotification::new( + "next", + SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(vec![])), + ); + assert!(matches!( + translate_for_session(commands, "current").as_slice(), + [Update::AvailableCommands { session_id, .. }] if session_id == "next" + )); + + let state = UpdateSessionNotification::new( + "next", + SessionUpdate::StateUpdate(wire::StateUpdate::Running(wire::RunningStateUpdate::new())), + ); + assert!(translate_for_session(state, "current").is_empty()); + } + #[test] fn dropped_shell_escaped_image_path_becomes_an_attachment() { let directory = tempfile::tempdir().unwrap(); @@ -1368,14 +1728,14 @@ mod tests { let blocks = prompt_blocks(&prompt).unwrap(); - let agentkit_acp::ContentBlock::Text(text) = &blocks[0] else { + let ContentBlock::Text(text) = &blocks[0] else { panic!("expected text block"); }; assert!(text.text.starts_with("describe [Image #1](file://")); - let agentkit_acp::ContentBlock::Image(image) = &blocks[1] else { + let ContentBlock::Image(image) = &blocks[1] else { panic!("expected image block"); }; - assert_eq!(image.mime_type, "image/png"); + assert_eq!(image.mime_type.to_string(), "image/png"); assert_eq!(image.data, "cG5n"); assert!( image @@ -1404,7 +1764,7 @@ mod tests { let ContentBlock::Audio(audio) = &blocks[1] else { panic!("expected audio block"); }; - assert_eq!(audio.mime_type, "audio/wav"); + assert_eq!(audio.mime_type.to_string(), "audio/wav"); assert_eq!(audio.data, "d2F2"); } @@ -1435,7 +1795,7 @@ mod tests { #[test] fn rendered_image_never_exposes_a_data_url() { let content = ContentBlock::Image( - agentkit_acp::ImageContent::new("c2VjcmV0", "image/png") + agent_client_protocol::schema::v2::ImageContent::new("c2VjcmV0", "image/png") .uri(Some("data:image/png;base64,c2VjcmV0".into())), ); @@ -1633,10 +1993,10 @@ a = [still text] #[test] fn uses_the_validated_acp_id_as_the_durable_session_id() { assert_eq!( - durable_session_id(&agentkit_acp::SessionId::new("s-123-4-5")).unwrap(), + durable_session_id(&wire::SessionId::new("s-123-4-5")).unwrap(), "s-123-4-5" ); - assert!(durable_session_id(&agentkit_acp::SessionId::new("bad/id")).is_err()); + assert!(durable_session_id(&wire::SessionId::new("bad/id")).is_err()); } #[test] @@ -1663,9 +2023,31 @@ a = [still text] #[cfg(test)] mod signal_tests { - use std::time::Duration; + use std::{future, time::Duration}; + + use super::{Stop, bounded_graceful_close}; + + #[tokio::test] + async fn a_stuck_close_is_bounded() { + let closed = bounded_graceful_close( + future::pending::<()>(), + future::pending(), + Duration::from_millis(1), + ) + .await; + assert!(closed.is_none()); + } - use super::Stop; + #[tokio::test] + async fn a_second_stop_escapes_a_stuck_close() { + let closed = bounded_graceful_close( + future::pending::<()>(), + future::ready(()), + Duration::from_secs(60), + ) + .await; + assert!(closed.is_none()); + } /// A client killed from outside must still reach its restore path, or it /// leaves the shell in raw mode with mouse reporting on. diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 5b392d6..ee4086e 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -2,7 +2,7 @@ use std::ops::Range; -use agentkit_acp::{ToolCallStatus, ToolKind}; +use agent_client_protocol::schema::v2::{ToolCallStatus, ToolKind}; use ratatui::{ Frame, layout::{Constraint, Layout, Position, Rect}, @@ -34,6 +34,7 @@ use super::{ const SIDE_BY_SIDE_WIDTH: u16 = 108; const GRAPH_WIDTH: u16 = 46; const MAX_PROMPT_ROWS: usize = 10; +const MAX_PENDING_STEER_ROWS: usize = 3; /// Rows of raw tool output rendered when a card is opened. const MAX_OUTPUT_ROWS: usize = 400; @@ -51,10 +52,12 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) { .clamp(1, MAX_PROMPT_ROWS) as u16 + 2; let logs_rows = if app.show_logs { 9 } else { 0 }; - let [header, body, logs, prompt, status] = Layout::vertical([ + let pending_rows = app.pending_steers.len().min(MAX_PENDING_STEER_ROWS) as u16; + let [header, body, logs, pending, prompt, status] = Layout::vertical([ Constraint::Length(1), Constraint::Min(3), Constraint::Length(logs_rows), + Constraint::Length(pending_rows), Constraint::Length(prompt_rows), Constraint::Length(1), ]) @@ -65,6 +68,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) { if app.show_logs { draw_logs(frame, app, logs); } + draw_pending_steers(frame, app, pending); draw_prompt(frame, app, prompt); draw_status(frame, app, status); if app.model_dialog.is_some() { @@ -746,7 +750,7 @@ fn tool_header(app: &App, call: &ToolCall, active: bool) -> Vec> { theme::text_color() }), ), - Span::styled(kind_label(call.kind).to_string(), theme::faint()), + Span::styled(kind_label(&call.kind).to_string(), theme::faint()), Span::styled( format!(" {}", theme::duration(call.elapsed())), theme::dim(), @@ -774,7 +778,7 @@ fn tool_header(app: &App, call: &ToolCall, active: bool) -> Vec> { } /// Tool kinds worth naming; `other` reads as noise next to the tool's title. -const fn kind_label(kind: ToolKind) -> &'static str { +fn kind_label(kind: &ToolKind) -> &'static str { match kind { ToolKind::Read => " read", ToolKind::Edit => " edit", @@ -982,11 +986,43 @@ fn draw_logs(frame: &mut Frame<'_>, app: &App, area: Rect) { frame.render_widget(Paragraph::new(lines), inner); } -fn draw_prompt(frame: &mut Frame<'_>, app: &App, area: Rect) { - let border = if app.working() { - theme::faint() +fn draw_pending_steers(frame: &mut Frame<'_>, app: &App, area: Rect) { + let visible = area.height as usize; + if visible == 0 || app.pending_steers.is_empty() { + return; + } + + let mut lines = Vec::with_capacity(visible); + let skip = if app.pending_steers.len() > visible && visible > 1 { + let hidden = app.pending_steers.len() - (visible - 1); + lines.push(Line::from(Span::styled( + format!(" … {hidden} earlier pending"), + theme::faint(), + ))); + hidden } else { + app.pending_steers.len().saturating_sub(visible) + }; + lines.extend(app.pending_steers.iter().skip(skip).map(|pending| { + let text = pending + .text + .split_whitespace() + .collect::>() + .join(" "); + Line::from(vec![ + Span::styled(" › ", theme::bold(theme::user_color())), + Span::styled(text, theme::bold(theme::user_color())), + Span::styled(" · pending", theme::faint()), + ]) + })); + frame.render_widget(Paragraph::new(lines), area); +} + +fn draw_prompt(frame: &mut Frame<'_>, app: &App, area: Rect) { + let border = if app.phase == Phase::Working && app.can_steer || app.phase == Phase::Idle { Style::default().fg(theme::accent_color()) + } else { + theme::faint() }; let block = Panel::bordered() .border_type(BorderType::Rounded) @@ -1006,7 +1042,14 @@ fn draw_prompt(frame: &mut Frame<'_>, app: &App, area: Rect) { // Keep the cursor's row on screen when the prompt is taller than the box. let first = cursor_row.saturating_sub(height.saturating_sub(1)); let lines: Vec> = if app.editor.text().is_empty() { - vec![Line::from(Span::styled("message kit…", theme::faint()))] + vec![Line::from(Span::styled( + if app.phase == Phase::Working && app.can_steer { + "steer kit…" + } else { + "message kit…" + }, + theme::faint(), + ))] } else { prompt_lines(rows, app.editor.text(), &app.available_commands) .into_iter() @@ -1057,6 +1100,10 @@ fn draw_status(frame: &mut Frame<'_>, app: &App, area: Rect) { ), Span::styled("stopping", Style::default().fg(theme::warn_color())), ], + Phase::Blocked => vec![Span::styled( + " waiting for input", + Style::default().fg(theme::warn_color()), + )], Phase::Working => vec![ Span::styled( format!(" {} ", theme::pulse(theme::Pulse::Status, app.tick)), @@ -1111,7 +1158,7 @@ fn compact(value: u64) -> String { mod tests { use std::path::PathBuf; - use agentkit_acp::ToolKind; + use agent_client_protocol::schema::v2::ToolKind; use ratatui::{Terminal, backend::TestBackend}; use super::{ @@ -1186,7 +1233,7 @@ mod tests { app.toast = None; for block in &mut app.blocks { let Block::Tool(call) = block else { continue }; - call.status = agentkit_acp::ToolCallStatus::Completed; + call.status = agent_client_protocol::schema::v2::ToolCallStatus::Completed; call.finished = Some(call.started + Duration::from_millis(120)); for (index, child) in call.children.iter_mut().enumerate() { child.millis = Some(40 * (index as u64 + 1)); @@ -1312,7 +1359,7 @@ mod tests { "127.0.0.1:7331".into(), ); app.push_user("check every source file".into()); - app.apply(Update::Text( + app.apply(Update::test_text( "Reading the tree first.\n\n- one\n- two\n\n```sh\ncargo check\n```".into(), )); app.apply(Update::ToolStarted { @@ -1360,6 +1407,52 @@ mod tests { .join("\n") } + #[test] + fn pending_steers_render_above_input_until_delivery() { + let mut app = App::new( + PathBuf::from("/Users/dev/projects/kit"), + "openai-subscription".into(), + "gpt-5.4".into(), + "127.0.0.1:7331".into(), + ); + app.can_steer = true; + app.apply(Update::State { + active: true, + steerable: true, + cancelled: false, + }); + app.apply(Update::SteerAccepted { + id: "first".into(), + text: "first pending".into(), + }); + app.apply(Update::SteerAccepted { + id: "second".into(), + text: "second pending".into(), + }); + + let frame = render(&mut app, 80, 18); + let first = frame.find("first pending").expect("first steer"); + let second = frame.find("second pending").expect("second steer"); + let input = frame.find("steer kit…").expect("steering input"); + assert!(first < second && second < input, "{frame}"); + assert_eq!(frame.matches("· pending").count(), 2, "{frame}"); + assert!( + !app.blocks + .iter() + .any(|block| matches!(block, Block::User(_))) + ); + + app.apply(Update::UserMessage { + id: "first".into(), + text: "first pending".into(), + append: false, + }); + let frame = render(&mut app, 80, 18); + assert_eq!(frame.matches("· pending").count(), 1, "{frame}"); + assert_eq!(app.pending_steers.len(), 1); + assert!(matches!(app.blocks.last(), Some(Block::User(text)) if text == "first pending")); + } + #[test] fn completed_turn_duration_is_rendered() { let mut app = App::new( @@ -1433,7 +1526,7 @@ mod tests { app.apply(Update::ToolUpdated { id: "call-1".into(), - status: Some(agentkit_acp::ToolCallStatus::Completed), + status: Some(agent_client_protocol::schema::v2::ToolCallStatus::Completed), script: None, output: Vec::new(), backgrounded: false, @@ -1499,24 +1592,26 @@ mod tests { let mut app = sample(); app.apply(Update::ToolUpdated { id: "call-1".into(), - status: Some(agentkit_acp::ToolCallStatus::Failed), + status: Some(agent_client_protocol::schema::v2::ToolCallStatus::Failed), script: None, output: vec!["exit code 1".into()], backgrounded: false, }); - app.apply(Update::TurnEnded { - id: None, - error: Some("model refused the request".into()), + app.apply(Update::State { + active: false, + steerable: false, + cancelled: false, }); app.apply(Update::Log("warn: retrying provider request".into())); app.show_logs = true; for index in 0..12 { app.push_user(format!("follow-up number {index}")); - app.apply(Update::Text(format!("answer number {index}"))); + app.apply(Update::test_text(format!("answer number {index}"))); } - app.apply(Update::TurnEnded { - id: None, - error: None, + app.apply(Update::State { + active: false, + steerable: false, + cancelled: false, }); let _ = render(&mut app, 100, 24); app.scroll_by(-6); @@ -1596,7 +1691,7 @@ mod tests { let mut app = sample(); app.apply(Update::ToolUpdated { id: "call-1".into(), - status: Some(agentkit_acp::ToolCallStatus::Completed), + status: Some(agent_client_protocol::schema::v2::ToolCallStatus::Completed), script: None, output: (0..40) .map(|index| format!("output line {index}")) @@ -1710,7 +1805,7 @@ mod tests { "gpt-5.4".into(), "0:0".into(), ); - app.apply(Update::Thought("still thinking".into())); + app.apply(Update::test_thought("still thinking".into())); refresh_transcript_cache(&mut app, 40); let first_rows = app.transcript_cache[0].as_ref().unwrap().rows.as_ptr(); @@ -1720,7 +1815,7 @@ mod tests { first_rows ); - app.apply(Update::Text("done".into())); + app.apply(Update::test_text("done".into())); refresh_transcript_cache(&mut app, 40); assert!(!app.transcript_dynamic.contains(&0)); let stable_rows = app.transcript_cache[0].as_ref().unwrap().rows.as_ptr(); @@ -1774,16 +1869,17 @@ mod tests { "gpt-5.4".into(), "0:0".into(), ); - for index in 0..100 { + for index in 0..99 { app.blocks.push(Block::Agent(format!("history {index}"))); } + app.apply(Update::test_text("history 99".into())); refresh_transcript_cache(&mut app, 12); let history_rows = app.transcript_cache[0].as_ref().unwrap().rows.as_ptr(); let history_revision = app.transcript_cache[0].as_ref().unwrap().revision; let tail_rows = app.transcript_cache[99].as_ref().unwrap().rows.as_ptr(); super::REFRESHED_TRANSCRIPT_BLOCKS.with(|count| count.set(0)); - app.apply(Update::Text(" changed".into())); + app.apply(Update::test_text(" changed".into())); refresh_transcript_cache(&mut app, 12); super::REFRESHED_TRANSCRIPT_BLOCKS.with(|count| assert_eq!(count.get(), 1));