From 215a09310dc3ee5504b68719f3b99ecac454089e Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 25 Aug 2026 22:29:06 +0100 Subject: [PATCH 01/12] feat(acp): add v2 session injection server --- Cargo.lock | 209 +++---- Cargo.toml | 26 +- src/main.rs | 22 +- src/protocols/acp.rs | 103 +++- src/protocols/acp/v2.rs | 1290 +++++++++++++++++++++++++++++++++++++++ src/protocols/http.rs | 39 +- src/runtime.rs | 17 +- src/session.rs | 51 ++ 8 files changed, 1605 insertions(+), 152 deletions(-) create mode 100644 src/protocols/acp/v2.rs diff --git a/Cargo.lock b/Cargo.lock index 222c62e..66ba928 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", @@ -339,9 +322,8 @@ dependencies = [ [[package]] name = "agentkit-tool-skills" -version = "0.10.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb5c594d40e5308f9a90bac127ba5ad728ba8e9845a811ced94a03c80633fe3e" +version = "0.10.7" +source = "git+https://github.com/danielkov/agentkit.git?rev=4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597#4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" dependencies = [ "agentkit-core", "agentkit-tools-core", @@ -356,8 +338,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 +647,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -864,9 +845,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 +921,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 +974,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 +1009,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -1054,9 +1035,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 +1123,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 +1172,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 +1201,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 +1224,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 +1248,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 +1333,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 +1356,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -1401,9 +1382,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 +1656,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -1715,9 +1696,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 +2050,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 +2138,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 +2399,7 @@ dependencies = [ [[package]] name = "kit" -version = "0.1.91" +version = "0.1.92" dependencies = [ "a2a-protocol-client", "a2a-protocol-server", @@ -2460,7 +2441,7 @@ dependencies = [ "libc", "opentelemetry 0.32.0", "opentelemetry-otlp", - "opentelemetry_sdk 0.32.1", + "opentelemetry_sdk", "ratatui", "reqwest", "rmcp", @@ -2548,9 +2529,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 +2724,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 +2786,7 @@ dependencies = [ "http", "opentelemetry 0.32.0", "opentelemetry-proto", - "opentelemetry_sdk 0.32.1", + "opentelemetry_sdk", "prost", "thiserror 2.0.18", "tokio", @@ -2820,27 +2801,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 +3157,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 +3333,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -3624,9 +3590,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 +3675,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -3812,7 +3778,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -3823,7 +3789,7 @@ checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -3859,7 +3825,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -4143,9 +4109,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 +4291,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -4577,15 +4543,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 +5228,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", "zbus_names", "zvariant", "zvariant_utils", @@ -5376,13 +5339,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 +5397,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", "zvariant_utils", ] @@ -5447,6 +5410,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..7d0a7c5 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" @@ -23,7 +23,7 @@ agentkit-plugins = "=0.10.7" agentkit-provider-openrouter = "=0.10.7" agentkit-task-manager = "=0.10.6" agentkit-tool-compose = { version = "=0.10.9", default-features = false, features = ["runlet"] } -agentkit-tool-skills = "=0.10.8" +agentkit-tool-skills = "=0.10.7" agentkit-tools-core = "=0.10.5" async-trait = "=0.1.92" atomicwrites = "=0.4.4" @@ -81,3 +81,21 @@ 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-tool-skills = { 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/src/main.rs b/src/main.rs index 62909e9..99af6ac 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, @@ -440,6 +448,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)] @@ -817,11 +828,13 @@ async fn main() -> Result<(), Box> { eprintln!("A2A listening on {bound}"); } if remote_acp { - eprintln!("ACP listening on http://{bound}/acp"); + eprintln!("ACP v1 listening on http://{bound}/acp"); + eprintln!("ACP v2 listening on http://{bound}/acp/v2"); } supervise_serve(runtime, sessions, no_stdio, http).await?; } Command::Acp { + protocol_version, root, model, provider, @@ -876,7 +889,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 +1506,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()); } diff --git a/src/protocols/acp.rs b/src/protocols/acp.rs index 32c3b2b..d4400da 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(()) } - fn close_gate_and_snapshot(&self) -> Vec { + 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, 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); + } } } } @@ -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, diff --git a/src/protocols/acp/v2.rs b/src/protocols/acp/v2.rs new file mode 100644 index 0000000..658dccc --- /dev/null +++ b/src/protocols/acp/v2.rs @@ -0,0 +1,1290 @@ +use std::{ + collections::HashMap, + path::PathBuf, + sync::{ + Arc, Mutex, Weak, + atomic::{AtomicBool, 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}; +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; + +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 sdk_error(error: AcpRuntimeError) -> agent_client_protocol::Error { + agent_client_protocol::util::internal_error(error.to_string()) +} + +fn conversion_error(error: impl ToString) -> AcpRuntimeError { + AcpRuntimeError::Sdk(error.to_string()) +} + +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), + Cancel, + 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".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 { + 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; + let _ = activation.send(()); + Ok(wire::NewSessionResponse::new(session_id).config_options(config_options)) + } + + 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())?; + 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().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, 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() + { + busy.store(false, Ordering::Release); + return Err(AcpRuntimeError::ClientClosed); + } + match response.await { + Ok(response) => response, + Err(_) => { + busy.store(false, Ordering::Release); + Err(AcpRuntimeError::ClientClosed) + } + } + } + + fn prompt_sender( + &self, + session_id: &wire::SessionId, + ) -> Result<(mpsc::Sender, Arc, 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)?; + Ok(( + session.commands.clone(), + Arc::clone(&session.busy), + session.integration.cancellation_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 (sender, handle) = self.sender_and_handle(¬ification.session_id)?; + handle.interrupt(); + sender + .send(Command::Cancel) + .await + .map_err(|_| AcpRuntimeError::ClientClosed) + } + + 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())) + } + + fn sender_and_handle( + &self, + session_id: &wire::SessionId, + ) -> Result<(mpsc::Sender, AcpSessionHandle), AcpRuntimeError> { + self.sessions + .lock() + .expect("ACP v2 session map poisoned") + .get(session_id) + .map(|session| (session.commands.clone(), session.integration.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)) => { + handle.prepare_injection_turn(); + 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::Cancel) => {} + 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 + && driver.submit_input(vec![Item::notification(event.message)]).is_ok() + { + drive_autonomous(&session_id, &integration, &mut driver, &sink).await; + } + } + event = tasks.next_event() => match event { + Some(TaskEvent::Completed(snapshot, _)) + if snapshot.kind == agentkit_task_manager::TaskKind::Background => + { + drive_autonomous(&session_id, &integration, &mut driver, &sink).await; + } + Some(_) => {} + None => break, + } + } + } +} + +async fn prepare_prompt( + session_id: &wire::SessionId, + integration: &AcpIntegration, + handle: &AcpSessionHandle, + driver: &mut LoopDriver, + command: PromptCommand, + sink: &ConnectionSink, +) -> 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| AcpRuntimeError::Loop(error.to_string()))?; + 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 = drive_prompt(driver, handle, cancellation_generation).await; + 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 fn drive_prompt( + driver: &mut LoopDriver, + handle: &AcpSessionHandle, + cancellation_generation: u64, +) -> wire::StopReason { + let cancellation = handle.cancellation_handle(); + loop { + let step = match driver.next().await { + Ok(step) => step, + Err(_) if cancellation.is_cancelled_since(cancellation_generation) => { + return wire::StopReason::Cancelled; + } + Err(_) => return error_stop_reason(), + }; + if cancellation.is_cancelled_since(cancellation_generation) { + return wire::StopReason::Cancelled; + } + match step { + LoopStep::Finished(result) => { + if result.finish_reason == FinishReason::ToolCall { + continue; + } + match handle.handle_injection_boundary(driver, true).await { + Ok(AcpInjectionBoundary::Delivered | AcpInjectionBoundary::Continue) => { + continue; + } + Ok(AcpInjectionBoundary::Stopped) => return wire::StopReason::Cancelled, + Ok(AcpInjectionBoundary::Finished) => { + return finish_reason_to_stop_reason(&result.finish_reason); + } + Err(_) => return error_stop_reason(), + } + } + LoopStep::Interrupt(LoopInterrupt::AwaitingInput(_)) => { + match handle.handle_injection_boundary(driver, true).await { + Ok(AcpInjectionBoundary::Delivered | AcpInjectionBoundary::Continue) => { + continue; + } + Ok(AcpInjectionBoundary::Stopped) => return wire::StopReason::Cancelled, + Ok(AcpInjectionBoundary::Finished) => return wire::StopReason::EndTurn, + Err(_) => return error_stop_reason(), + } + } + LoopStep::Interrupt(LoopInterrupt::AfterToolResult(_)) => { + match handle.handle_injection_boundary(driver, false).await { + Ok(AcpInjectionBoundary::Stopped) => return wire::StopReason::Cancelled, + Err(_) => return error_stop_reason(), + _ => {} + } + } + LoopStep::Interrupt(LoopInterrupt::ApprovalRequest(_)) => { + if driver.cancel_pending_approvals().await.is_err() { + return error_stop_reason(); + } + } + } + } +} + +async fn drive_autonomous( + session_id: &wire::SessionId, + integration: &AcpIntegration, + driver: &mut LoopDriver, + sink: &ConnectionSink, +) { + if send_state( + sink, + session_id, + wire::StateUpdate::Running(wire::RunningStateUpdate::new()), + ) + .is_err() + { + return; + } + let stop_reason = loop { + match driver.next().await { + Ok(LoopStep::Finished(result)) if result.finish_reason == FinishReason::ToolCall => {} + Ok(LoopStep::Finished(result)) => { + break finish_reason_to_stop_reason(&result.finish_reason); + } + Ok(LoopStep::Interrupt(LoopInterrupt::AfterToolResult(_))) => {} + Ok(LoopStep::Interrupt(LoopInterrupt::ApprovalRequest(_))) => { + if driver.cancel_pending_approvals().await.is_err() { + break error_stop_reason(); + } + } + Ok(LoopStep::Interrupt(LoopInterrupt::AwaitingInput(_))) => { + break wire::StopReason::EndTurn; + } + Err(LoopError::Cancelled) => break wire::StopReason::Cancelled, + Err(_) => break error_stop_reason(), + } + }; + let _ = integration.flush_session_updates(session_id).await; + integration.finish_prompt(session_id); + let _ = send_state( + sink, + session_id, + wire::StateUpdate::Idle(wire::IdleStateUpdate::new().stop_reason(stop_reason)), + ); +} + +fn send_state( + sink: &ConnectionSink, + 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 | FinishReason::ToolCall | FinishReason::Other(_) => { + wire::StopReason::EndTurn + } + FinishReason::MaxTokens => wire::StopReason::MaxTokens, + FinishReason::Cancelled => wire::StopReason::Cancelled, + FinishReason::Blocked | FinishReason::Error => error_stop_reason(), + } +} + +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], +) -> Result, AcpRuntimeError> { + let v1 = super::config_options(current, reasoning, catalog); + serde_json::from_value(serde_json::to_value(v1).map_err(conversion_error)?) + .map_err(conversion_error) +} + +fn set_v2_config( + adapter: &SelectableAdapter, + catalog: &[crate::provider::ModelGroup], + request: wire::SetSessionConfigOptionRequest, +) -> Result { + let request = serde_json::from_value(serde_json::to_value(request).map_err(conversion_error)?) + .map_err(conversion_error)?; + let response = super::set_config(adapter, catalog, request)?; + serde_json::from_value(serde_json::to_value(response).map_err(conversion_error)?) + .map_err(conversion_error) +} + +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)), + )); + } + } + } + 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() +} + +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> { + component(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 = component(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 || { + component(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 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 { + let result = state.new_session(request, connection.clone()).await; + let notification = result + .as_ref() + .ok() + .map(|response| available_commands_update(response.session_id.clone())); + responder.respond_with_result(result.map_err(sdk_error))?; + if let Some(notification) = notification { + connection.send_notification(notification)?; + } + Ok(()) + })?; + 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 super::*; + + #[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 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); + + assert_eq!(replay.len(), 2); + assert!(matches!( + replay[0].update, + wire::SessionUpdate::UserMessage(_) + )); + assert!(matches!( + replay[1].update, + wire::SessionUpdate::AgentMessage(_) + )); + } + + #[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..49c25ad 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!( @@ -397,6 +409,29 @@ mod tests { assert_eq!(response.status(), reqwest::StatusCode::OK); assert!(response.headers().contains_key("acp-connection-id")); + 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": 2, + "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 .write_all( diff --git a/src/runtime.rs b/src/runtime.rs index 0d086a9..5cdf2a7 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, @@ -136,10 +138,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, } @@ -854,11 +856,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/session.rs b/src/session.rs index 74efaef..e07cc9a 100644 --- a/src/session.rs +++ b/src/session.rs @@ -586,6 +586,45 @@ fn default_directory() -> Result { .ok_or_else(|| "HOME is unset; cannot locate durable sessions".into()) } +/// Lists durable transcript ids without acquiring their mutation locks. +pub(crate) fn list_ids() -> Result, String> { + list_ids_in(&default_directory()?) +} + +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 list session directory {}: {error}", + directory.display() + )); + } + }; + 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) +} + fn preferred_transcript( directory: &Path, root: &Path, @@ -1275,6 +1314,18 @@ mod tests { assert!(session_directory(root.path()).join("abc.lock").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 writer_fails_closed_when_another_owner_wins_recovery_lock() { let root = tempfile::tempdir().unwrap(); From 13e308deeeebee16d529428654af7d063929efb6 Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 25 Aug 2026 22:29:06 +0100 Subject: [PATCH 02/12] feat(tui): migrate bundled client to ACP v2 --- src/tui/app.rs | 703 ++++++++++++++++++++++++++++++++------------- src/tui/command.rs | 21 ++ src/tui/mod.rs | 629 +++++++++++++++++++++++++++++----------- src/tui/ui.rs | 62 ++-- 4 files changed, 1033 insertions(+), 382 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 654d301..07a58d4 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -2,7 +2,7 @@ use std::{ cmp::Reverse, - collections::BTreeSet, + collections::{BTreeSet, HashMap}, 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::{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,24 @@ 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 user message accepted 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 +66,7 @@ pub enum Update { backgrounded: bool, }, /// A tool call changed status or produced output. + #[cfg(test)] ToolUpdated { id: String, status: Option, @@ -54,26 +74,57 @@ 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, + }, /// 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. @@ -144,8 +195,13 @@ pub struct SubmittedPrompt { 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 +359,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 +382,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 +399,8 @@ 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, + 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 +572,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 +586,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 +644,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 +660,8 @@ 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, + message_blocks: HashMap::new(), agent_stream_sealed: false, latest_agent_source: String::new(), compacting: false, @@ -783,6 +851,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 +1019,107 @@ 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) { + if self.phase == Phase::Idle { + self.agent_stream_sealed = true; + return; + } + self.close_thought(); + self.agent_stream_sealed = true; + let interrupted = cancelled || self.phase == Phase::Cancelling; + self.phase = Phase::Idle; + self.turn_started = 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.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"); + } + } + pub fn apply(&mut self, update: Update) { match update { Update::A2aAddress(address) => self.a2a = address, @@ -961,39 +1131,15 @@ 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; - } 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)), - } - } + Update::UserMessage { id, text, append } => { + 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::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::ToolStarted { id, title, @@ -1017,6 +1163,7 @@ impl App { backgrounded, })); } + #[cfg(test)] Update::ToolUpdated { id, status, @@ -1053,82 +1200,96 @@ 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::ProcessExited(error) => { + self.finish_turn(false); + self.push_block(Block::Error(error)); + } } if self.follow { self.scroll = usize::MAX; @@ -1136,8 +1297,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 +1415,7 @@ 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.compacting = false; self.usage = None; self.scroll = usize::MAX; @@ -1264,21 +1431,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. @@ -1586,13 +1752,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 +1830,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 +2030,7 @@ mod tests { time::{Duration, Instant}, }; - use agentkit_acp::{ToolCallStatus, ToolKind}; + use agent_client_protocol::schema::v2::{ToolCallStatus, ToolKind}; use agentkit_core::{DataRef, Item, ItemKind, MediaPart, MetadataMap, Modality, Part}; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; @@ -1914,13 +2105,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,9 +2284,15 @@ 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, + }); + app.apply(Update::State { + active: false, + steerable: false, + cancelled: false, }); let Some(Block::Tool(call)) = app.blocks.last() else { panic!("expected a tool block"); @@ -2076,22 +2302,31 @@ mod tests { } #[test] - fn stale_turn_end_cannot_finish_a_newer_turn() { + 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] @@ -2115,13 +2350,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 +2391,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,7 +2503,7 @@ 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"); }; @@ -2320,7 +2564,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 +2576,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 +2610,61 @@ 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::UserMessage { + id: "injected-1".into(), + text: prompt.text, + append: false, + }); + 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 +2754,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 +2772,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 +2791,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 +2803,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 +2845,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 +2853,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..0b45d47 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,6 +72,30 @@ 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); @@ -76,9 +103,6 @@ const HANDSHAKE: Duration = Duration::from_secs(30); 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 +130,7 @@ fn current_model_choice(options: Option<&[SessionConfigOption]>) -> Option Some(select.current_value.to_string()), _ => None, @@ -122,7 +146,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 +184,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 +194,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 +287,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 +299,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 +362,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 +387,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() .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 +462,10 @@ 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)) - })?; - let mut terminal = enter().map_err(agent_client_protocol::Error::into_internal_error)?; let mut app = App::new( @@ -441,16 +474,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 { @@ -488,89 +520,144 @@ 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.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(|_| ()) + } else { + connection + .send_request(wire::PromptRequest::new(session_id.clone(), blocks)) + .block_task() + .await + .map(|_| ()) + }; + if let Err(error) = outcome { + app.paste(&prompt.text); + app.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 +723,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 +763,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,15 +783,6 @@ 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. @@ -985,9 +1069,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 +1077,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)) } }); } @@ -1102,63 +1182,217 @@ fn leave(terminal: DefaultTerminal) { ratatui::restore(); } -fn durable_session_id(session_id: &agentkit_acp::SessionId) -> Result { +async fn request_resume( + connection: &agent_client_protocol::ConnectionTo, + 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 => 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::State { + active: false, + steerable: false, + cancelled: matches!(idle.stop_reason, Some(wire::StopReason::Cancelled)), + }], + _ => 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 { @@ -1210,9 +1444,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,9 +1481,11 @@ 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; @@ -1255,7 +1493,7 @@ mod tests { 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 +1514,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 +1528,65 @@ 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::State { + active: false, + steerable: false, + cancelled: false + }] + )); + } + + #[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 +1665,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 +1701,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 +1732,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 +1930,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] diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 5b392d6..073ba1d 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}, @@ -746,7 +746,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 +774,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", @@ -983,10 +983,10 @@ fn draw_logs(frame: &mut Frame<'_>, app: &App, area: Rect) { } fn draw_prompt(frame: &mut Frame<'_>, app: &App, area: Rect) { - let border = if app.working() { - theme::faint() - } else { + 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 +1006,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 +1064,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 +1122,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 +1197,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 +1323,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 { @@ -1433,7 +1444,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 +1510,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 +1609,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 +1723,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 +1733,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 +1787,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)); From 7911fa6c8b162c32ba5a77ec9cdf19f986f98c19 Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 25 Aug 2026 22:29:06 +0100 Subject: [PATCH 03/12] docs: describe ACP v2 sessions and steering --- docs/user/getting-started-and-configuration.md | 9 +++++---- docs/user/subagents-and-acp-harnesses.md | 4 ++-- docs/user/tui-and-sessions.md | 16 ++++++++++------ 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/docs/user/getting-started-and-configuration.md b/docs/user/getting-started-and-configuration.md index 1bb48af..cebd109 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 is available at `/acp`, and ACP v2 is available at `/acp/v2`; both 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..3b71c3f 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. From 9bc5fcf5060070800e362deaba6ab781fde67b1e Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 25 Aug 2026 22:29:06 +0100 Subject: [PATCH 04/12] docs: record background subagent startup issue --- .../background-subagents-stuck-starting.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 docs/issues/background-subagents-stuck-starting.md 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. From 4ff70ad79a76035be7df462677d1e61086263871 Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 25 Aug 2026 23:22:37 +0100 Subject: [PATCH 05/12] fix(acp): harden v2 routing and session lifecycle --- .../user/getting-started-and-configuration.md | 2 +- src/protocols/acp.rs | 17 +- src/protocols/acp/v2.rs | 364 ++++++++++++++---- src/protocols/http.rs | 27 +- src/runtime.rs | 19 +- src/runtime/tests.rs | 26 +- src/session.rs | 246 +++++++++++- 7 files changed, 598 insertions(+), 103 deletions(-) diff --git a/docs/user/getting-started-and-configuration.md b/docs/user/getting-started-and-configuration.md index cebd109..82f43eb 100644 --- a/docs/user/getting-started-and-configuration.md +++ b/docs/user/getting-started-and-configuration.md @@ -136,7 +136,7 @@ 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 v1 is available at `/acp`, and ACP v2 is available at `/acp/v2`; both 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. +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. Select the wire version explicitly with `--protocol-version 1|2`; omitting it defaults to ACP v1: diff --git a/src/protocols/acp.rs b/src/protocols/acp.rs index d4400da..13cb573 100644 --- a/src/protocols/acp.rs +++ b/src/protocols/acp.rs @@ -620,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(), @@ -1330,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, @@ -2678,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 index 658dccc..8e9e6eb 100644 --- a/src/protocols/acp/v2.rs +++ b/src/protocols/acp/v2.rs @@ -15,8 +15,10 @@ use agentkit_acp::{ AcpSessionUpdateSink, wire, }, }; -use agentkit_core::{CancellationController, FinishReason, Item, ItemKind, Part, SessionId}; -use agentkit_loop::{LoopDriver, LoopError, LoopInterrupt, LoopStep, ModelSession}; +use agentkit_core::{ + CancellationController, FinishReason, Item, ItemKind, Part, SessionId, ToolOutput, +}; +use agentkit_loop::{LoopDriver, LoopInterrupt, LoopStep, ModelSession}; use agentkit_task_manager::{TaskEvent, TaskManagerHandle}; use async_trait::async_trait; use tokio::sync::{mpsc, oneshot, watch}; @@ -46,10 +48,6 @@ fn sdk_error(error: AcpRuntimeError) -> agent_client_protocol::Error { agent_client_protocol::util::internal_error(error.to_string()) } -fn conversion_error(error: impl ToString) -> AcpRuntimeError { - AcpRuntimeError::Sdk(error.to_string()) -} - fn claim_prompt(busy: &AtomicBool) -> Result<(), AcpRuntimeError> { busy.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .map(|_| ()) @@ -87,7 +85,6 @@ struct PromptCommand { enum Command { Prompt(PromptCommand), - Cancel, SetConfig { request: wire::SetSessionConfigOptionRequest, reply: oneshot::Sender>, @@ -173,9 +170,9 @@ impl Server { &self, request: wire::InitializeRequest, ) -> Result { - if request.protocol_version != wire::ProtocolVersion::V2 { + if request.protocol_version < wire::ProtocolVersion::V2 { return Err(AcpRuntimeError::Unsupported( - "ACP v2 requires protocol version 2".into(), + "ACP v2 requires protocol version 2 or newer".into(), )); } Ok(wire::InitializeResponse::new( @@ -237,6 +234,17 @@ impl Server { 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, @@ -279,7 +287,7 @@ impl Server { .map(|cursor| parse_cursor(cursor.as_ref())) .transpose()? .unwrap_or(0); - let ids = crate::session::list_ids().map_err(AcpRuntimeError::Loop)?; + 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(), @@ -325,7 +333,7 @@ impl Server { .reasoning_effort() .map_err(AcpRuntimeError::Loop)?; let catalog = model_catalog(¤t).await; - let config_options = v2_config_options(¤t, reasoning, &catalog)?; + 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(); @@ -419,7 +427,8 @@ impl Server { &self, request: wire::PromptRequest, ) -> Result, AcpRuntimeError> { - let (sender, busy, cancellation_generation) = self.prompt_sender(&request.session_id)?; + let (sender, busy, handle, cancellation_generation) = + self.prompt_sender(&request.session_id)?; let (reply, response) = oneshot::channel(); if sender .send(Command::Prompt(PromptCommand { @@ -430,12 +439,14 @@ impl Server { .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) } @@ -445,16 +456,28 @@ impl Server { fn prompt_sender( &self, session_id: &wire::SessionId, - ) -> Result<(mpsc::Sender, Arc, u64), AcpRuntimeError> { + ) -> 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), - session.integration.cancellation_handle().generation(), + handle, + generation, )) } @@ -475,12 +498,16 @@ impl Server { &self, notification: wire::CancelSessionNotification, ) -> Result<(), AcpRuntimeError> { - let (sender, handle) = self.sender_and_handle(¬ification.session_id)?; - handle.interrupt(); - sender - .send(Command::Cancel) - .await - .map_err(|_| AcpRuntimeError::ClientClosed) + 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( @@ -519,18 +546,6 @@ impl Server { .ok_or_else(|| AcpRuntimeError::SessionNotFound(session_id.to_string())) } - fn sender_and_handle( - &self, - session_id: &wire::SessionId, - ) -> Result<(mpsc::Sender, AcpSessionHandle), AcpRuntimeError> { - self.sessions - .lock() - .expect("ACP v2 session map poisoned") - .get(session_id) - .map(|session| (session.commands.clone(), session.integration.clone())) - .ok_or_else(|| AcpRuntimeError::SessionNotFound(session_id.to_string())) - } - async fn detach_compose( &self, request: DetachComposeRequest, @@ -602,7 +617,6 @@ async fn session_actor(actor: SessionActor) biased; command = commands.recv() => match command { Some(Command::Prompt(command)) => { - handle.prepare_injection_turn(); let result = prepare_prompt( &session_id, &integration, @@ -617,7 +631,6 @@ async fn session_actor(actor: SessionActor) eprintln!("ACP v2 prompt failed for {session_id}: {error}"); } } - Some(Command::Cancel) => {} Some(Command::SetConfig { request, reply }) => { let result = set_v2_config(&adapter, &catalog, request); let _ = reply.send(result); @@ -639,14 +652,28 @@ async fn session_actor(actor: SessionActor) if let Some(event) = event && driver.submit_input(vec![Item::notification(event.message)]).is_ok() { - drive_autonomous(&session_id, &integration, &mut driver, &sink).await; + drive_autonomous( + &session_id, + &integration, + &handle, + &busy, + &mut driver, + &sink, + ).await; } } event = tasks.next_event() => match event { Some(TaskEvent::Completed(snapshot, _)) if snapshot.kind == agentkit_task_manager::TaskKind::Background => { - drive_autonomous(&session_id, &integration, &mut driver, &sink).await; + drive_autonomous( + &session_id, + &integration, + &handle, + &busy, + &mut driver, + &sink, + ).await; } Some(_) => {} None => break, @@ -728,7 +755,13 @@ async fn drive_prompt( Err(_) if cancellation.is_cancelled_since(cancellation_generation) => { return wire::StopReason::Cancelled; } - Err(_) => return error_stop_reason(), + Err(_) => match handle.handle_injection_boundary(driver, true).await { + Ok(AcpInjectionBoundary::Delivered | AcpInjectionBoundary::Continue) => { + continue; + } + Ok(AcpInjectionBoundary::Stopped) => return wire::StopReason::Cancelled, + Ok(AcpInjectionBoundary::Finished) | Err(_) => return error_stop_reason(), + }, }; if cancellation.is_cancelled_since(cancellation_generation) { return wire::StopReason::Cancelled; @@ -775,12 +808,21 @@ async fn drive_prompt( } } -async fn drive_autonomous( +async fn drive_autonomous( session_id: &wire::SessionId, integration: &AcpIntegration, + handle: &AcpSessionHandle, + busy: &AtomicBool, driver: &mut LoopDriver, sink: &ConnectionSink, ) { + if claim_prompt(busy).is_err() { + return; + } + handle.prepare_injection_turn(); + integration.finish_prompt(session_id); + let cancellation_generation = handle.cancellation_handle().generation(); + handle.start_injection_turn(); if send_state( sink, session_id, @@ -788,34 +830,20 @@ async fn drive_autonomous( ) .is_err() { + handle.stop_injection_turn(); + busy.store(false, Ordering::Release); return; } - let stop_reason = loop { - match driver.next().await { - Ok(LoopStep::Finished(result)) if result.finish_reason == FinishReason::ToolCall => {} - Ok(LoopStep::Finished(result)) => { - break finish_reason_to_stop_reason(&result.finish_reason); - } - Ok(LoopStep::Interrupt(LoopInterrupt::AfterToolResult(_))) => {} - Ok(LoopStep::Interrupt(LoopInterrupt::ApprovalRequest(_))) => { - if driver.cancel_pending_approvals().await.is_err() { - break error_stop_reason(); - } - } - Ok(LoopStep::Interrupt(LoopInterrupt::AwaitingInput(_))) => { - break wire::StopReason::EndTurn; - } - Err(LoopError::Cancelled) => break wire::StopReason::Cancelled, - Err(_) => break error_stop_reason(), - } - }; + let stop_reason = drive_prompt(driver, handle, cancellation_generation).await; let _ = integration.flush_session_updates(session_id).await; integration.finish_prompt(session_id); + handle.stop_injection_turn(); let _ = send_state( sink, session_id, wire::StateUpdate::Idle(wire::IdleStateUpdate::new().stop_reason(stop_reason)), ); + busy.store(false, Ordering::Release); } fn send_state( @@ -831,12 +859,13 @@ fn send_state( fn finish_reason_to_stop_reason(reason: &FinishReason) -> wire::StopReason { match reason { - FinishReason::Completed | FinishReason::ToolCall | FinishReason::Other(_) => { - wire::StopReason::EndTurn - } + FinishReason::Completed => wire::StopReason::EndTurn, + FinishReason::ToolCall => wire::StopReason::EndTurn, FinishReason::MaxTokens => wire::StopReason::MaxTokens, FinishReason::Cancelled => wire::StopReason::Cancelled, - FinishReason::Blocked | FinishReason::Error => error_stop_reason(), + FinishReason::Blocked => wire::StopReason::Refusal, + FinishReason::Error => error_stop_reason(), + FinishReason::Other(reason) => wire::StopReason::Other(reason.clone()), } } @@ -848,10 +877,53 @@ fn v2_config_options( current: &crate::provider::ModelSelection, reasoning: Option, catalog: &[crate::provider::ModelGroup], -) -> Result, AcpRuntimeError> { - let v1 = super::config_options(current, reasoning, catalog); - serde_json::from_value(serde_json::to_value(v1).map_err(conversion_error)?) - .map_err(conversion_error) +) -> 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( @@ -859,11 +931,44 @@ fn set_v2_config( catalog: &[crate::provider::ModelGroup], request: wire::SetSessionConfigOptionRequest, ) -> Result { - let request = serde_json::from_value(serde_json::to_value(request).map_err(conversion_error)?) - .map_err(conversion_error)?; - let response = super::set_config(adapter, catalog, request)?; - serde_json::from_value(serde_json::to_value(response).map_err(conversion_error)?) - .map_err(conversion_error) + 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 { @@ -944,7 +1049,8 @@ fn transcript_replay( result.call_id.to_string(), )) .status(status) - .raw_output(super::tool_output_raw(&result.output)), + .raw_output(super::tool_output_raw(&result.output)) + .content(replay_tool_output_content(&result.output)), )); } } @@ -976,6 +1082,26 @@ fn replay_content(part: &Part) -> Option { 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 } @@ -984,7 +1110,7 @@ pub async fn serve_with_registry( runtime: Arc, registry: SessionRegistry, ) -> Result<(), AcpRuntimeError> { - component(runtime, registry)? + v2_router(runtime, registry)? .connect_to(agent_client_protocol::Stdio::new()) .await .map_err(|error| AcpRuntimeError::Sdk(error.to_string())) @@ -995,7 +1121,7 @@ async fn serve_transport( transport: impl ConnectTo + 'static, ) -> Result<(), AcpRuntimeError> { let registry = SessionRegistry::new(); - let result = component(runtime, registry.clone())? + let result = v2_router(runtime, registry.clone())? .connect_to(transport) .await .map_err(|error| AcpRuntimeError::Sdk(error.to_string())); @@ -1005,7 +1131,7 @@ 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()) + v2_router(Arc::clone(&runtime), registry.clone()) .expect("Kit's fixed ACP v2 integration must build") }) .with_options(agent_client_protocol_http::ServerOptions { @@ -1016,7 +1142,16 @@ pub(crate) fn http_router(runtime: Arc, registry: SessionRegistry) -> a .into_router() } -fn component( +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> { @@ -1201,6 +1336,8 @@ fn component( #[cfg(test)] mod tests { + use serde_json::json; + use super::*; #[test] @@ -1246,6 +1383,15 @@ mod tests { .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()); @@ -1269,7 +1415,12 @@ mod tests { ]; 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, @@ -1281,6 +1432,69 @@ mod tests { )); } + #[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); diff --git a/src/protocols/http.rs b/src/protocols/http.rs index 49c25ad..14981dd 100644 --- a/src/protocols/http.rs +++ b/src/protocols/http.rs @@ -401,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() @@ -408,6 +431,8 @@ 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")) @@ -418,7 +443,7 @@ mod tests { "id": 2, "method": "initialize", "params": { - "protocolVersion": 2, + "protocolVersion": 99, "info": { "name": "kit-test", "version": "0" } } })) diff --git a/src/runtime.rs b/src/runtime.rs index 5cdf2a7..f3da7f5 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -82,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; } @@ -94,7 +93,7 @@ impl SessionSelection { SessionRequest { id: id.into(), resume: true, - force: false, + force, }, configured, ) @@ -163,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; 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 e07cc9a..01e6892 100644 --- a/src/session.rs +++ b/src/session.rs @@ -16,7 +16,8 @@ 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 PREVIOUS_SCHEMA_VERSION: u32 = 2; const LEGACY_SCHEMA_VERSION: u32 = 1; static NEXT_ID: AtomicU64 = AtomicU64::new(1); @@ -25,6 +26,8 @@ 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")] @@ -44,6 +47,7 @@ struct Writer { session_id: String, generation: u64, path: PathBuf, + workspace_root: PathBuf, file: File, lock: SessionLock, } @@ -108,8 +112,10 @@ pub(crate) fn load_in( session_id: &str, ) -> Result, String> { validate_id(session_id)?; - let path = preferred_transcript(directory, root, session_id)?; + let workspace_root = canonical_workspace(root); + let path = preferred_transcript(directory, &workspace_root, session_id)?; let (mut items, _) = read_records(&path, session_id)?; + ensure_workspace(&path, session_id, &workspace_root)?; crate::transcript::repair_unanswered_tool_calls(&mut items); Ok(items) } @@ -210,10 +216,11 @@ fn open_with_initial_timestamps_in( if !resume && initial.is_empty() { return Err("a new session requires an initial transcript".into()); } + let workspace_root = canonical_workspace(root); fs::create_dir_all(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 legacy = legacy_transcript(&workspace_root, session_id); let lock = SessionLock::acquire(lock_path(directory, session_id), force)?; let global_exists = path .try_exists() @@ -238,6 +245,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 +273,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,6 +357,7 @@ 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, }; @@ -349,6 +374,7 @@ 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()), }; @@ -541,7 +567,7 @@ 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 ) { return Err(format!( "unsupported session schema version {} on line {} (Kit supports {})", @@ -559,7 +585,7 @@ fn read_records(path: &Path, session_id: &str) -> Result<(Vec, u64), Strin match (record.item, record.replacement) { (Some(item), None) => items.push(item), (None, Some(replacement)) - if record.schema_version == SCHEMA_VERSION && !replacement.is_empty() => + if record.schema_version >= PREVIOUS_SCHEMA_VERSION && !replacement.is_empty() => { items = replacement; } @@ -578,6 +604,27 @@ fn read_records(path: &Path, session_id: &str) -> Result<(Vec, u64), Strin Ok((items, expected - 1)) } +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 default_directory() -> Result { env::var_os("HOME") .filter(|home| !home.is_empty()) @@ -586,9 +633,111 @@ fn default_directory() -> Result { .ok_or_else(|| "HOME is unset; cannot locate durable sessions".into()) } -/// Lists durable transcript ids without acquiring their mutation locks. -pub(crate) fn list_ids() -> Result, String> { - list_ids_in(&default_directory()?) +fn workspace_directory(root: &Path) -> PathBuf { + root.join(".kit/sessions") +} + +fn transcript_workspace(path: &Path, session_id: &str) -> Result, String> { + let file = + File::open(path).map_err(|error| format!("could not read {}: {error}", path.display()))?; + let mut workspace = 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))?; + 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 + { + return Err(format!( + "session {session_id:?} belongs to workspace {}, not {}", + stored.display(), + root.display() + )); + } + Ok(()) +} + +/// 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 legacy_directory = workspace_directory(&root); + let mut ids = Vec::new(); + for id in list_ids_in(global_directory)? { + let path = transcript_path(global_directory, &id); + read_records(&path, &id)?; + if transcript_workspace(&path, &id)?.as_deref() == Some(root.as_path()) { + ids.push(id); + } + } + for id in list_ids_in(&legacy_directory)? { + let global = transcript_path(global_directory, &id); + let visible = if global + .try_exists() + .map_err(|error| format!("could not inspect {}: {error}", global.display()))? + { + read_records(&global, &id)?; + transcript_workspace(&global, &id)? + .as_deref() + .is_none_or(|stored| stored == root) + } else { + true + }; + if visible { + 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); + let global = transcript_path(global_directory, session_id); + let legacy = legacy_transcript(&root, session_id); + if global + .try_exists() + .map_err(|error| format!("could not inspect {}: {error}", global.display()))? + { + read_records(&global, session_id)?; + return Ok(match transcript_workspace(&global, session_id)? { + Some(stored) => stored == root, + None => legacy + .try_exists() + .map_err(|error| format!("could not inspect {}: {error}", legacy.display()))?, + }); + } + legacy + .try_exists() + .map_err(|error| format!("could not inspect {}: {error}", legacy.display())) } pub(crate) fn list_ids_in(directory: &Path) -> Result, String> { @@ -690,7 +839,7 @@ fn transcript_path(directory: &Path, session_id: &str) -> PathBuf { } 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 { @@ -853,8 +1002,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] @@ -1197,6 +1347,7 @@ mod tests { schema_version: SCHEMA_VERSION, session_id: "abc".into(), generation: 1, + workspace_root: None, item: Some(item.clone()), replacement: None, }; @@ -1253,6 +1404,7 @@ mod tests { schema_version: SCHEMA_VERSION, session_id: "abc".into(), generation: 1, + workspace_root: None, item: Some(Item::text(ItemKind::System, "legacy")), replacement: None, }; @@ -1326,6 +1478,80 @@ mod tests { assert_eq!(list_ids_in(directory.path()).unwrap(), ["alpha", "zeta"]); } + #[test] + fn workspace_metadata_isolates_global_sessions() { + 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 opened = open_in( + &first, + storage.path(), + "isolated", + false, + false, + vec![Item::text(ItemKind::System, "system")], + ) + .unwrap(); + drop(opened); + + assert_eq!( + list_ids_for_workspace(&first, storage.path()).unwrap(), + ["isolated"] + ); + assert!( + list_ids_for_workspace(&second, storage.path()) + .unwrap() + .is_empty() + ); + assert!(belongs_to_workspace_in(&first, storage.path(), "isolated").unwrap()); + assert!(!belongs_to_workspace_in(&second, storage.path(), "isolated").unwrap()); + let error = open_in(&second, storage.path(), "isolated", true, false, Vec::new()) + .err() + .expect("cross-workspace resume must fail"); + assert!(error.contains("belongs to workspace")); + } + + #[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); + assert_eq!( + transcript_workspace(&storage.path().join("legacy.jsonl"), "legacy").unwrap(), + Some(canonical_workspace(&first)) + ); + assert!(!belongs_to_workspace_in(&second, storage.path(), "legacy").unwrap()); + } + #[test] fn writer_fails_closed_when_another_owner_wins_recovery_lock() { let root = tempfile::tempdir().unwrap(); From 93bc153f1905d3c22d5c3e475b8fccb4fe1f3c56 Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 25 Aug 2026 23:22:43 +0100 Subject: [PATCH 06/12] fix(tui): launch ACP v2 and preserve terminal state --- docs/user/tui-and-sessions.md | 12 +-- src/acp_child.rs | 6 ++ src/main.rs | 48 +++++++++--- src/tui/app.rs | 138 +++++++++++++++++++++++++++++++--- src/tui/mod.rs | 56 ++++++++++---- 5 files changed, 223 insertions(+), 37 deletions(-) diff --git a/docs/user/tui-and-sessions.md b/docs/user/tui-and-sessions.md index 3b71c3f..e78eff5 100644 --- a/docs/user/tui-and-sessions.md +++ b/docs/user/tui-and-sessions.md @@ -106,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`. -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. +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. -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. +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 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` @@ -162,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 99af6ac..ab81b37 100644 --- a/src/main.rs +++ b/src/main.rs @@ -431,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, @@ -656,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> { @@ -680,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); @@ -761,6 +781,7 @@ async fn main() -> Result<(), Box> { remote_acp, no_a2a, no_stdio, + stdio_protocol_version, server_credential_file, mcp, session_id, @@ -828,10 +849,10 @@ async fn main() -> Result<(), Box> { eprintln!("A2A listening on {bound}"); } if remote_acp { - eprintln!("ACP v1 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, @@ -1519,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([ @@ -1558,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/tui/app.rs b/src/tui/app.rs index 07a58d4..852e105 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -13,7 +13,7 @@ use std::ffi::OsStr; #[cfg(any(target_os = "macos", target_os = "linux"))] use std::process::{Command, Stdio}; -use agent_client_protocol::schema::v2::{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::{ @@ -100,6 +100,8 @@ pub enum Update { 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. @@ -1089,13 +1091,40 @@ impl App { } 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) { if self.phase == Phase::Idle { self.agent_stream_sealed = true; return; } self.close_thought(); self.agent_stream_sealed = true; - let interrupted = cancelled || self.phase == Phase::Cancelling; + let interrupted = self.phase == Phase::Cancelling; self.phase = Phase::Idle; self.turn_started = None; self.compacting = false; @@ -1105,7 +1134,11 @@ impl App { && call.running() && !call.backgrounded { - call.status = ToolCallStatus::Completed; + call.status = if successful { + ToolCallStatus::Completed + } else { + ToolCallStatus::Failed + }; call.finished = Some(Instant::now()); call.finish_running_children(); finished.push(index); @@ -1117,6 +1150,8 @@ impl App { } if interrupted { self.note("turn interrupted"); + } else if let Some(notice) = notice { + self.note(notice); } } @@ -1286,8 +1321,9 @@ impl App { self.finish_turn(cancelled); } } + Update::Stopped(reason) => self.finish_with_stop_reason(reason), Update::ProcessExited(error) => { - self.finish_turn(false); + self.finish_turn_with_outcome(false, None); self.push_block(Block::Error(error)); } } @@ -1518,6 +1554,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 @@ -2030,7 +2081,7 @@ mod tests { time::{Duration, Instant}, }; - use agent_client_protocol::schema::v2::{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}; @@ -2289,11 +2340,7 @@ mod tests { steerable: true, cancelled: false, }); - app.apply(Update::State { - active: false, - steerable: false, - cancelled: false, - }); + app.apply(Update::Stopped(Some(StopReason::EndTurn))); let Some(Block::Tool(call)) = app.blocks.last() else { panic!("expected a tool block"); }; @@ -2301,6 +2348,47 @@ mod tests { assert!(!app.working()); } + #[test] + 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 Some(Block::Notice(notice)) = app.blocks.last() else { + panic!("expected 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(); @@ -2510,6 +2598,36 @@ mod tests { 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(); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 0b45d47..8439156 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -525,7 +525,7 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( Ok(blocks) => blocks, Err(error) => { app.paste(&prompt.text); - app.attachments = prompt.attachments; + app.restore_attachments(prompt.attachments); app.note(error); continue; } @@ -550,7 +550,7 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( }; if let Err(error) = outcome { app.paste(&prompt.text); - app.attachments = prompt.attachments; + app.restore_attachments(prompt.attachments); app.note(format!("message was not accepted: {}", error.message)); } } @@ -1249,7 +1249,11 @@ fn translate(notification: UpdateSessionNotification) -> (String, Vec) { let output = match &update.content { MaybeUndefined::Value(content) => Some(output_of(Some(content))), MaybeUndefined::Null => Some(Vec::new()), - MaybeUndefined::Undefined => None, + 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()), @@ -1332,11 +1336,7 @@ fn translate(notification: UpdateSessionNotification) -> (String, Vec) { steerable: false, cancelled: false, }], - wire::StateUpdate::Idle(idle) => vec![Update::State { - active: false, - steerable: false, - cancelled: matches!(idle.stop_reason, Some(wire::StopReason::Cancelled)), - }], + wire::StateUpdate::Idle(idle) => vec![Update::Stopped(idle.stop_reason)], _ => Vec::new(), }, _ => Vec::new(), @@ -1434,6 +1434,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() { @@ -1489,6 +1503,8 @@ mod tests { }; 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, @@ -1561,11 +1577,25 @@ mod tests { ); assert!(matches!( translate_for_session(idle, "session").as_slice(), - [Update::State { - active: false, - steerable: false, - cancelled: false - }] + [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"] )); } From b11c7efa199239498962756aa8465f93abf6224a Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 25 Aug 2026 23:51:13 +0100 Subject: [PATCH 07/12] fix: harden ACP v2 session lifecycle --- src/protocols/acp/v2.rs | 225 ++++++++++++++++++-------- src/session.rs | 340 ++++++++++++++++++++++++++++++---------- src/tui/mod.rs | 66 ++++++-- 3 files changed, 472 insertions(+), 159 deletions(-) diff --git a/src/protocols/acp/v2.rs b/src/protocols/acp/v2.rs index 8e9e6eb..ef9b1fe 100644 --- a/src/protocols/acp/v2.rs +++ b/src/protocols/acp/v2.rs @@ -18,7 +18,7 @@ use agentkit_acp::{ use agentkit_core::{ CancellationController, FinishReason, Item, ItemKind, Part, SessionId, ToolOutput, }; -use agentkit_loop::{LoopDriver, LoopInterrupt, LoopStep, ModelSession}; +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}; @@ -44,10 +44,42 @@ fn available_commands_update(session_id: wire::SessionId) -> wire::UpdateSession ) } +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(|_| ()) @@ -186,7 +218,7 @@ impl Server { self: &Arc, request: wire::NewSessionRequest, connection: V2ConnectionTo, - ) -> Result { + ) -> Result<(wire::NewSessionResponse, oneshot::Sender<()>), AcpRuntimeError> { let claim = self.runtime.claim_session()?; let attached = self .attach_session( @@ -206,8 +238,10 @@ impl Server { activation, .. } = attached; - let _ = activation.send(()); - Ok(wire::NewSessionResponse::new(session_id).config_options(config_options)) + Ok(( + wire::NewSessionResponse::new(session_id).config_options(config_options), + activation, + )) } async fn resume_session( @@ -649,31 +683,37 @@ async fn session_actor(actor: SessionActor) } }, event = mcp_events.recv() => { - if let Some(event) = event - && driver.submit_input(vec![Item::notification(event.message)]).is_ok() - { - drive_autonomous( - &session_id, - &integration, - &handle, - &busy, - &mut driver, - &sink, - ).await; + 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 => { - drive_autonomous( + if let Err(error) = drive_autonomous( &session_id, &integration, &handle, &busy, &mut driver, &sink, - ).await; + ).await { + eprintln!("ACP v2 autonomous turn failed for {session_id}: {error}"); + } } Some(_) => {} None => break, @@ -698,7 +738,7 @@ async fn prepare_prompt( let prepared = integration.prompt_to_items(&request).and_then(|items| { driver .submit_input(items) - .map_err(|error| AcpRuntimeError::Loop(error.to_string()))?; + .map_err(|error| map_loop_error(session_id, &error))?; integration.begin_prompt(session_id) }); let user_message_id = match prepared { @@ -728,7 +768,7 @@ async fn prepare_prompt( session_id, wire::StateUpdate::Running(wire::RunningStateUpdate::new()), )?; - let stop_reason = drive_prompt(driver, handle, cancellation_generation).await; + let stop_reason = drive_prompt(session_id, driver, handle, cancellation_generation).await?; let _ = integration.flush_session_updates(session_id).await; integration.finish_prompt(session_id); send_state( @@ -744,27 +784,22 @@ async fn prepare_prompt( } async fn drive_prompt( + session_id: &wire::SessionId, driver: &mut LoopDriver, handle: &AcpSessionHandle, cancellation_generation: u64, -) -> wire::StopReason { +) -> Result { let cancellation = handle.cancellation_handle(); loop { let step = match driver.next().await { Ok(step) => step, - Err(_) if cancellation.is_cancelled_since(cancellation_generation) => { - return wire::StopReason::Cancelled; + Err(error) => { + handle.stop_injection_turn(); + return loop_error_stop_reason(session_id, &error); } - Err(_) => match handle.handle_injection_boundary(driver, true).await { - Ok(AcpInjectionBoundary::Delivered | AcpInjectionBoundary::Continue) => { - continue; - } - Ok(AcpInjectionBoundary::Stopped) => return wire::StopReason::Cancelled, - Ok(AcpInjectionBoundary::Finished) | Err(_) => return error_stop_reason(), - }, }; if cancellation.is_cancelled_since(cancellation_generation) { - return wire::StopReason::Cancelled; + return Ok(wire::StopReason::Cancelled); } match step { LoopStep::Finished(result) => { @@ -775,11 +810,13 @@ async fn drive_prompt( Ok(AcpInjectionBoundary::Delivered | AcpInjectionBoundary::Continue) => { continue; } - Ok(AcpInjectionBoundary::Stopped) => return wire::StopReason::Cancelled, + Ok(AcpInjectionBoundary::Stopped) => { + return Ok(wire::StopReason::Cancelled); + } Ok(AcpInjectionBoundary::Finished) => { - return finish_reason_to_stop_reason(&result.finish_reason); + return Ok(finish_reason_to_stop_reason(&result.finish_reason)); } - Err(_) => return error_stop_reason(), + Err(_) => return Ok(error_stop_reason()), } } LoopStep::Interrupt(LoopInterrupt::AwaitingInput(_)) => { @@ -787,21 +824,26 @@ async fn drive_prompt( Ok(AcpInjectionBoundary::Delivered | AcpInjectionBoundary::Continue) => { continue; } - Ok(AcpInjectionBoundary::Stopped) => return wire::StopReason::Cancelled, - Ok(AcpInjectionBoundary::Finished) => return wire::StopReason::EndTurn, - Err(_) => return error_stop_reason(), + Ok(AcpInjectionBoundary::Stopped) => { + return Ok(wire::StopReason::Cancelled); + } + Ok(AcpInjectionBoundary::Finished) => return Ok(wire::StopReason::EndTurn), + Err(_) => return Ok(error_stop_reason()), } } LoopStep::Interrupt(LoopInterrupt::AfterToolResult(_)) => { match handle.handle_injection_boundary(driver, false).await { - Ok(AcpInjectionBoundary::Stopped) => return wire::StopReason::Cancelled, - Err(_) => return error_stop_reason(), + Ok(AcpInjectionBoundary::Stopped) => { + return Ok(wire::StopReason::Cancelled); + } + Err(_) => return Ok(error_stop_reason()), _ => {} } } LoopStep::Interrupt(LoopInterrupt::ApprovalRequest(_)) => { - if driver.cancel_pending_approvals().await.is_err() { - return error_stop_reason(); + if let Err(error) = driver.cancel_pending_approvals().await { + handle.stop_injection_turn(); + return loop_error_stop_reason(session_id, &error); } } } @@ -815,35 +857,33 @@ async fn drive_autonomous( busy: &AtomicBool, driver: &mut LoopDriver, sink: &ConnectionSink, -) { +) -> Result<(), AcpRuntimeError> { if claim_prompt(busy).is_err() { - return; + return Ok(()); } handle.prepare_injection_turn(); integration.finish_prompt(session_id); let cancellation_generation = handle.cancellation_handle().generation(); handle.start_injection_turn(); - if send_state( - sink, - session_id, - wire::StateUpdate::Running(wire::RunningStateUpdate::new()), - ) - .is_err() - { - handle.stop_injection_turn(); - busy.store(false, Ordering::Release); - return; + let result = async { + send_state( + sink, + session_id, + wire::StateUpdate::Running(wire::RunningStateUpdate::new()), + )?; + let stop_reason = drive_prompt(session_id, driver, handle, cancellation_generation).await?; + let _ = integration.flush_session_updates(session_id).await; + send_state( + sink, + session_id, + wire::StateUpdate::Idle(wire::IdleStateUpdate::new().stop_reason(stop_reason)), + ) } - let stop_reason = drive_prompt(driver, handle, cancellation_generation).await; - let _ = integration.flush_session_updates(session_id).await; + .await; integration.finish_prompt(session_id); handle.stop_injection_turn(); - let _ = send_state( - sink, - session_id, - wire::StateUpdate::Idle(wire::IdleStateUpdate::new().stop_reason(stop_reason)), - ); busy.store(false, Ordering::Release); + result } fn send_state( @@ -1175,16 +1215,15 @@ pub(crate) fn component( let state = Arc::clone(&state); let connection = cx.clone(); cx.spawn(async move { - let result = state.new_session(request, connection.clone()).await; - let notification = result - .as_ref() - .ok() - .map(|response| available_commands_update(response.session_id.clone())); - responder.respond_with_result(result.map_err(sdk_error))?; - if let Some(notification) = notification { - connection.send_notification(notification)?; + 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(()) })?; Ok(()) } @@ -1340,6 +1379,56 @@ mod tests { use super::*; + #[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 + ); + } + #[test] fn available_commands_advertises_only_compact() { let notification = available_commands_update(wire::SessionId::new("session")); diff --git a/src/session.rs b/src/session.rs index 01e6892..259e66a 100644 --- a/src/session.rs +++ b/src/session.rs @@ -152,13 +152,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(()), @@ -217,25 +237,23 @@ fn open_with_initial_timestamps_in( return Err("a new session requires an initial transcript".into()); } let workspace_root = canonical_workspace(root); - fs::create_dir_all(directory) + 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(&workspace_root, session_id); - let lock = SessionLock::acquire(lock_path(directory, session_id), force)?; - let global_exists = path + let path = transcript_path(&scoped_directory, session_id); + let legacy = legacy_transcript_for_workspace(directory, &workspace_root, session_id)?; + let lock = SessionLock::acquire(lock_path(&scoped_directory, session_id), force)?; + let scoped_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 _legacy_lock = lock_legacy_for_migration(&legacy)?; - read_records(&legacy, session_id)?; - copy_new(&legacy, &path)?; - } else if !resume && (global_exists || legacy_exists) { + if resume && !scoped_exists { + let legacy = legacy + .as_ref() + .ok_or_else(|| format!("session {session_id:?} does not exist"))?; + let _legacy_lock = lock_legacy_for_migration(legacy)?; + read_records(legacy, session_id)?; + copy_new(legacy, &path)?; + } else if !resume && (scoped_exists || legacy.is_some()) { return Err(format!( "session {session_id:?} already exists; use --resume" )); @@ -679,32 +697,34 @@ pub(crate) fn list_ids(root: &Path) -> Result, String> { 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)?; - if transcript_workspace(&path, &id)?.as_deref() == Some(root.as_path()) { - ids.push(id); - } - } - for id in list_ids_in(&legacy_directory)? { - let global = transcript_path(global_directory, &id); - let visible = if global + let legacy = legacy_transcript(&root, &id); + let legacy_exists = legacy .try_exists() - .map_err(|error| format!("could not inspect {}: {error}", global.display()))? + .map_err(|error| format!("could not inspect {}: {error}", legacy.display()))?; + let stored_workspace = transcript_workspace(&path, &id)?; + if stored_workspace.as_deref() == Some(root.as_path()) + || stored_workspace.is_none() && legacy_exists { - read_records(&global, &id)?; - transcript_workspace(&global, &id)? - .as_deref() - .is_none_or(|stored| stored == root) - } else { - true - }; - if visible { 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) @@ -721,23 +741,19 @@ fn belongs_to_workspace_in( ) -> Result { validate_id(session_id)?; let root = canonical_workspace(root); - let global = transcript_path(global_directory, session_id); - let legacy = legacy_transcript(&root, session_id); - if global + let scoped = transcript_path( + &workspace_storage_directory(global_directory, &root), + session_id, + ); + if scoped .try_exists() - .map_err(|error| format!("could not inspect {}: {error}", global.display()))? + .map_err(|error| format!("could not inspect {}: {error}", scoped.display()))? { - read_records(&global, session_id)?; - return Ok(match transcript_workspace(&global, session_id)? { - Some(stored) => stored == root, - None => legacy - .try_exists() - .map_err(|error| format!("could not inspect {}: {error}", legacy.display()))?, - }); + read_records(&scoped, session_id)?; + ensure_workspace(&scoped, session_id, &root)?; + return Ok(true); } - legacy - .try_exists() - .map_err(|error| format!("could not inspect {}: {error}", legacy.display())) + Ok(legacy_transcript_for_workspace(global_directory, &root, session_id)?.is_some()) } pub(crate) fn list_ids_in(directory: &Path) -> Result, String> { @@ -779,14 +795,42 @@ fn preferred_transcript( root: &Path, session_id: &str, ) -> Result { - let global = transcript_path(directory, session_id); - if global + let scoped = transcript_path(&workspace_storage_directory(directory, root), session_id); + if scoped .try_exists() - .map_err(|error| format!("could not inspect {}: {error}", global.display()))? + .map_err(|error| format!("could not inspect {}: {error}", scoped.display()))? { - Ok(global) + return Ok(scoped); + } + Ok(legacy_transcript_for_workspace(directory, root, session_id)?.unwrap_or(scoped)) +} + +fn legacy_transcript_for_workspace( + directory: &Path, + root: &Path, + session_id: &str, +) -> Result, String> { + let unscoped = transcript_path(directory, session_id); + let legacy = legacy_transcript(root, session_id); + let legacy_exists = legacy + .try_exists() + .map_err(|error| format!("could not inspect {}: {error}", legacy.display()))?; + if unscoped + .try_exists() + .map_err(|error| format!("could not inspect {}: {error}", unscoped.display()))? + { + read_records(&unscoped, session_id)?; + match transcript_workspace(&unscoped, session_id)? { + Some(stored) if stored == root => return Ok(Some(unscoped)), + None if legacy_exists => return Ok(Some(legacy)), + _ => {} + } + } + if legacy_exists { + read_records(&legacy, session_id)?; + Ok(Some(legacy)) } else { - Ok(legacy_transcript(root, session_id)) + Ok(None) } } @@ -834,6 +878,11 @@ fn copy_new(source: &Path, destination: &Path) -> Result<(), String> { Ok(()) } +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")) } @@ -903,11 +952,33 @@ 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 } #[test] @@ -1059,7 +1130,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"); @@ -1093,7 +1164,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!({ @@ -1322,9 +1393,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(), @@ -1434,7 +1505,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!( @@ -1463,7 +1534,7 @@ 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] @@ -1479,7 +1550,7 @@ mod tests { } #[test] - fn workspace_metadata_isolates_global_sessions() { + fn identical_ids_round_trip_independently_across_workspaces() { let storage = tempfile::tempdir().unwrap(); let roots = tempfile::tempdir().unwrap(); let first = roots.path().join("first"); @@ -1487,32 +1558,137 @@ mod tests { fs::create_dir_all(&first).unwrap(); fs::create_dir_all(&second).unwrap(); - let opened = open_in( + let first_open = open_in( &first, storage.path(), - "isolated", + "shared", false, false, - vec![Item::text(ItemKind::System, "system")], + 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, + }; + let unscoped = super::transcript_path(storage.path(), "legacy-id"); + fs::write( + &unscoped, + format!("{}\n", serde_json::to_string(&record).unwrap()), ) .unwrap(); - drop(opened); + 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(), - ["isolated"] + ["legacy-id"] ); assert!( list_ids_for_workspace(&second, storage.path()) .unwrap() .is_empty() ); - assert!(belongs_to_workspace_in(&first, storage.path(), "isolated").unwrap()); - assert!(!belongs_to_workspace_in(&second, storage.path(), "isolated").unwrap()); - let error = open_in(&second, storage.path(), "isolated", true, false, Vec::new()) - .err() - .expect("cross-workspace resume must fail"); - assert!(error.contains("belongs to workspace")); + 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] @@ -1545,8 +1721,12 @@ mod tests { 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(&storage.path().join("legacy.jsonl"), "legacy").unwrap(), + transcript_workspace(&migrated, "legacy").unwrap(), Some(canonical_workspace(&first)) ); assert!(!belongs_to_workspace_in(&second, storage.path(), "legacy").unwrap()); @@ -1564,12 +1744,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/mod.rs b/src/tui/mod.rs index 8439156..7e2c85f 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -99,6 +99,8 @@ struct DetachComposeResponse { /// 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. @@ -466,6 +468,10 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( let active_session_id = durable_session_id(&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( @@ -483,8 +489,6 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( let mut events = EventStream::new(); let mut ticker = tokio::time::interval(TICK); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - let mut stop = - Stop::new().map_err(agent_client_protocol::Error::into_internal_error)?; let result: Result<(), agent_client_protocol::Error> = async { loop { terminal @@ -786,12 +790,18 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( // 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 @@ -1141,6 +1151,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)] @@ -1152,6 +1164,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())?, }) @@ -1165,6 +1178,7 @@ impl Stop { #[cfg(unix)] async fn requested(&mut self) { tokio::select! { + _ = self.interrupt.recv() => {} _ = self.terminate.recv() => {} _ = self.hangup.recv() => {} } @@ -1176,6 +1190,18 @@ 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); @@ -1990,9 +2016,31 @@ a = [still text] #[cfg(test)] mod signal_tests { - use std::time::Duration; + use std::{future, time::Duration}; + + use super::{Stop, bounded_graceful_close}; - use super::Stop; + #[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()); + } + + #[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. From a0f975a4a14dcc283632f2255d4001715b24d421 Mon Sep 17 00:00:00 2001 From: daniel Date: Wed, 26 Aug 2026 01:11:55 +0100 Subject: [PATCH 08/12] fix: close ACP v2 final review gaps --- ...edits-not-applied-to-requested-worktree.md | 13 + src/protocols/acp/v2.rs | 672 ++++++++++- src/session.rs | 1047 +++++++++++++++-- 3 files changed, 1593 insertions(+), 139 deletions(-) create mode 100644 docs/issues/subagent-edits-not-applied-to-requested-worktree.md 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/src/protocols/acp/v2.rs b/src/protocols/acp/v2.rs index ef9b1fe..4e3625e 100644 --- a/src/protocols/acp/v2.rs +++ b/src/protocols/acp/v2.rs @@ -3,7 +3,7 @@ use std::{ path::PathBuf, sync::{ Arc, Mutex, Weak, - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicU64, Ordering}, }, }; @@ -34,6 +34,7 @@ use super::{ }; 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( @@ -728,7 +729,7 @@ async fn prepare_prompt( handle: &AcpSessionHandle, driver: &mut LoopDriver, command: PromptCommand, - sink: &ConnectionSink, + sink: &impl AcpSessionUpdateSink, ) -> Result<(), AcpRuntimeError> { let PromptCommand { request, @@ -768,7 +769,22 @@ async fn prepare_prompt( session_id, wire::StateUpdate::Running(wire::RunningStateUpdate::new()), )?; - let stop_reason = drive_prompt(session_id, driver, handle, cancellation_generation).await?; + 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( @@ -783,22 +799,58 @@ async fn prepare_prompt( result } -async fn drive_prompt( +#[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, - handle: &AcpSessionHandle, + control: &C, cancellation_generation: u64, -) -> Result { - let cancellation = handle.cancellation_handle(); +) -> Result +where + S: ModelSession + Send + 'static, + C: TurnControl, +{ loop { let step = match driver.next().await { Ok(step) => step, Err(error) => { - handle.stop_injection_turn(); + 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 cancellation.is_cancelled_since(cancellation_generation) { + if control.is_cancelled_since(cancellation_generation) { return Ok(wire::StopReason::Cancelled); } match step { @@ -806,7 +858,14 @@ async fn drive_prompt( if result.finish_reason == FinishReason::ToolCall { continue; } - match handle.handle_injection_boundary(driver, true).await { + 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; } @@ -816,11 +875,17 @@ async fn drive_prompt( Ok(AcpInjectionBoundary::Finished) => { return Ok(finish_reason_to_stop_reason(&result.finish_reason)); } - Err(_) => return Ok(error_stop_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 handle.handle_injection_boundary(driver, true).await { + match control.handle_injection_boundary(driver, true).await { Ok(AcpInjectionBoundary::Delivered | AcpInjectionBoundary::Continue) => { continue; } @@ -828,21 +893,36 @@ async fn drive_prompt( return Ok(wire::StopReason::Cancelled); } Ok(AcpInjectionBoundary::Finished) => return Ok(wire::StopReason::EndTurn), - Err(_) => return Ok(error_stop_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::AfterToolResult(_)) => { - match handle.handle_injection_boundary(driver, false).await { + match control.handle_injection_boundary(driver, false).await { Ok(AcpInjectionBoundary::Stopped) => { return Ok(wire::StopReason::Cancelled); } - Err(_) => return Ok(error_stop_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::ApprovalRequest(_)) => { if let Err(error) = driver.cancel_pending_approvals().await { - handle.stop_injection_turn(); + control.stop_injection_turn(); + if control.is_cancelled_since(cancellation_generation) { + return Ok(wire::StopReason::Cancelled); + } return loop_error_stop_reason(session_id, &error); } } @@ -856,7 +936,7 @@ async fn drive_autonomous( handle: &AcpSessionHandle, busy: &AtomicBool, driver: &mut LoopDriver, - sink: &ConnectionSink, + sink: &impl AcpSessionUpdateSink, ) -> Result<(), AcpRuntimeError> { if claim_prompt(busy).is_err() { return Ok(()); @@ -871,7 +951,22 @@ async fn drive_autonomous( session_id, wire::StateUpdate::Running(wire::RunningStateUpdate::new()), )?; - let stop_reason = drive_prompt(session_id, driver, handle, cancellation_generation).await?; + 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, @@ -886,8 +981,51 @@ async fn drive_autonomous( 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: &ConnectionSink, + sink: &impl AcpSessionUpdateSink, session_id: &wire::SessionId, state: wire::StateUpdate, ) -> Result<(), AcpRuntimeError> { @@ -1377,8 +1515,183 @@ pub(crate) fn component( 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(); @@ -1429,6 +1742,327 @@ mod tests { ); } + #[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")); diff --git a/src/session.rs b/src/session.rs index 259e66a..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, @@ -17,6 +17,7 @@ use agentkit_loop::{TranscriptEvent, TranscriptObserver}; use serde::{Deserialize, Serialize}; 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); @@ -32,6 +33,8 @@ struct Record { 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. @@ -113,9 +116,9 @@ pub(crate) fn load_in( ) -> Result, String> { validate_id(session_id)?; let workspace_root = canonical_workspace(root); - let path = preferred_transcript(directory, &workspace_root, session_id)?; - let (mut items, _) = read_records(&path, session_id)?; - ensure_workspace(&path, session_id, &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) } @@ -241,19 +244,18 @@ fn open_with_initial_timestamps_in( fs::create_dir_all(&scoped_directory) .map_err(|error| format!("could not create session directory: {error}"))?; let path = transcript_path(&scoped_directory, session_id); - let legacy = legacy_transcript_for_workspace(directory, &workspace_root, session_id)?; let lock = SessionLock::acquire(lock_path(&scoped_directory, session_id), force)?; - let scoped_exists = path - .try_exists() - .map_err(|error| format!("could not inspect {}: {error}", path.display()))?; - if resume && !scoped_exists { - let legacy = legacy - .as_ref() - .ok_or_else(|| format!("session {session_id:?} does not exist"))?; - let _legacy_lock = lock_legacy_for_migration(legacy)?; - read_records(legacy, session_id)?; - copy_new(legacy, &path)?; - } else if !resume && (scoped_exists || legacy.is_some()) { + 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)?; + } + } else if authority.is_some() { return Err(format!( "session {session_id:?} already exists; use --resume" )); @@ -378,6 +380,7 @@ impl Writer { workspace_root: Some(self.workspace_root.clone()), item: Some(item.clone()), replacement: None, + redirect: None, }; self.write_record(record, generation) } @@ -395,6 +398,7 @@ impl Writer { workspace_root: Some(self.workspace_root.clone()), item: None, replacement: Some(transcript.to_vec()), + redirect: None, }; self.write_record(record, generation) } @@ -496,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(); @@ -512,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() @@ -573,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))?; @@ -585,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 | PREVIOUS_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 { @@ -600,26 +651,52 @@ 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 >= PREVIOUS_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 { @@ -643,6 +720,11 @@ fn canonical_workspace(root: &Path) -> PathBuf { 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 { env::var_os("HOME") .filter(|home| !home.is_empty()) @@ -656,13 +738,21 @@ fn workspace_directory(root: &Path) -> PathBuf { } fn transcript_workspace(path: &Path, session_id: &str) -> Result, String> { - let file = - File::open(path).map_err(|error| format!("could not read {}: {error}", path.display()))?; + 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, + 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 BufReader::new(file).lines().enumerate() { - let line = - line.map_err(|error| format!("could not read transcript line {}: {error}", index + 1))?; - let record: Record = serde_json::from_str(&line) + 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!( @@ -709,14 +799,8 @@ fn list_ids_for_workspace(root: &Path, global_directory: &Path) -> Result Result { validate_id(session_id)?; let root = canonical_workspace(root); - let scoped = transcript_path( - &workspace_storage_directory(global_directory, &root), - session_id, - ); - if scoped - .try_exists() - .map_err(|error| format!("could not inspect {}: {error}", scoped.display()))? - { - read_records(&scoped, session_id)?; - ensure_workspace(&scoped, session_id, &root)?; - return Ok(true); - } - Ok(legacy_transcript_for_workspace(global_directory, &root, session_id)?.is_some()) + Ok(select_authority(global_directory, &root, session_id)?.is_some()) } pub(crate) fn list_ids_in(directory: &Path) -> Result, String> { @@ -790,92 +862,409 @@ pub(crate) fn list_ids_in(directory: &Path) -> Result, String> { Ok(ids) } -fn preferred_transcript( +struct Authority { + items: Vec, + legacy_histories: Vec, +} + +struct HistoryCandidate { + path: PathBuf, + history: TranscriptHistory, +} + +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 { +) -> Result, String> { let scoped = transcript_path(&workspace_storage_directory(directory, root), session_id); - if scoped - .try_exists() - .map_err(|error| format!("could not inspect {}: {error}", scoped.display()))? - { - return Ok(scoped); + 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(legacy_transcript_for_workspace(directory, root, session_id)?.unwrap_or(scoped)) + Ok(authority.map(|candidate| Authority { + items: candidate.history.items, + legacy_histories, + })) } -fn legacy_transcript_for_workspace( +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 unscoped = transcript_path(directory, session_id); - let legacy = legacy_transcript(root, session_id); - let legacy_exists = legacy - .try_exists() - .map_err(|error| format!("could not inspect {}: {error}", legacy.display()))?; - if unscoped +) -> 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 sync session directory {}: {error}", + parent.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}", unscoped.display()))? + .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) { - read_records(&unscoped, session_id)?; - match transcript_workspace(&unscoped, session_id)? { - Some(stored) if stored == root => return Ok(Some(unscoped)), - None if legacy_exists => return Ok(Some(legacy)), - _ => {} + 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. } - if legacy_exists { - read_records(&legacy, session_id)?; - Ok(Some(legacy)) - } else { - Ok(None) +} + +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 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), - Err(error) => { - return Err(format!( - "could not inspect legacy session lock {}: {error}", - path.display() - )); +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 }; - 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)) + 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 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) - .map_err(|error| { - format!( - "could not copy legacy session to {}: {error}", - destination.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() - )); +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) } - Ok(()) } fn workspace_storage_directory(directory: &Path, root: &Path) -> PathBuf { @@ -981,6 +1370,42 @@ mod tests { &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] fn generated_ids_are_valid_unique_and_durable() { let first = new_id(); @@ -1045,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(); @@ -1421,6 +1870,7 @@ mod tests { workspace_root: None, item: Some(item.clone()), replacement: None, + redirect: None, }; fs::write( legacy.join("abc.jsonl"), @@ -1457,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(), @@ -1467,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(); @@ -1478,6 +2276,7 @@ mod tests { workspace_root: None, item: Some(Item::text(ItemKind::System, "legacy")), replacement: None, + redirect: None, }; fs::write( legacy.join("abc.jsonl"), @@ -1485,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] @@ -1639,6 +2445,7 @@ mod tests { 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( From a06d685044e05379b88e92979c96782e5c10cb98 Mon Sep 17 00:00:00 2001 From: daniel Date: Wed, 26 Aug 2026 01:25:48 +0100 Subject: [PATCH 09/12] fix: preserve main behavior after rebase --- Cargo.lock | 5 +++-- Cargo.toml | 3 +-- src/tui/app.rs | 38 +++++++++++++++++++++++++++----------- 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 66ba928..b387839 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -322,8 +322,9 @@ dependencies = [ [[package]] name = "agentkit-tool-skills" -version = "0.10.7" -source = "git+https://github.com/danielkov/agentkit.git?rev=4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597#4299f31ec1455ea8ee4c46b4dce8e5bec4fdc597" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5c594d40e5308f9a90bac127ba5ad728ba8e9845a811ced94a03c80633fe3e" dependencies = [ "agentkit-core", "agentkit-tools-core", diff --git a/Cargo.toml b/Cargo.toml index 7d0a7c5..6475117 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ agentkit-plugins = "=0.10.7" agentkit-provider-openrouter = "=0.10.7" agentkit-task-manager = "=0.10.6" agentkit-tool-compose = { version = "=0.10.9", default-features = false, features = ["runlet"] } -agentkit-tool-skills = "=0.10.7" +agentkit-tool-skills = "=0.10.8" agentkit-tools-core = "=0.10.5" async-trait = "=0.1.92" atomicwrites = "=0.4.4" @@ -95,7 +95,6 @@ agentkit-plugins = { git = "https://github.com/danielkov/agentkit.git", rev = "4 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-tool-skills = { 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/src/tui/app.rs b/src/tui/app.rs index 852e105..ca4fa76 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1125,8 +1125,8 @@ impl App { 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.turn_started = None; self.compacting = false; let mut finished = Vec::new(); for (index, block) in self.blocks.iter_mut().enumerate() { @@ -1153,6 +1153,9 @@ impl App { } 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) { @@ -2341,9 +2344,15 @@ mod tests { cancelled: false, }); app.apply(Update::Stopped(Some(StopReason::EndTurn))); - let Some(Block::Tool(call)) = app.blocks.last() else { - panic!("expected a tool block"); - }; + 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()); } @@ -2373,9 +2382,15 @@ mod tests { }); app.apply(Update::Stopped(Some(reason))); - let Some(Block::Notice(notice)) = app.blocks.last() else { - panic!("expected terminal notice"); - }; + 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 @@ -2420,12 +2435,13 @@ mod tests { #[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!( From f090fc5c1ec179e182c1725c03bab11b52fb8673 Mon Sep 17 00:00:00 2001 From: daniel Date: Wed, 26 Aug 2026 11:38:33 +0100 Subject: [PATCH 10/12] fix(tui): use ACP v2 client transport --- src/tui/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 7e2c85f..451b48a 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -391,7 +391,7 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( 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: UpdateSessionNotification, _cx| { let current = notification_session.lock().ok().map(|id| id.clone()); @@ -1209,7 +1209,7 @@ fn leave(terminal: DefaultTerminal) { } async fn request_resume( - connection: &agent_client_protocol::ConnectionTo, + connection: &agent_client_protocol::V2ConnectionTo, session_id: wire::SessionId, root: PathBuf, ) -> Result { From 2b27b30a0bada760e4ad14536d3ef114b3996c48 Mon Sep 17 00:00:00 2001 From: daniel Date: Wed, 26 Aug 2026 11:56:50 +0100 Subject: [PATCH 11/12] fix(tui): show accepted steering messages --- src/tui/app.rs | 15 +++++++++++++++ src/tui/mod.rs | 20 ++++++++++++++------ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index ca4fa76..7e8ce81 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -2779,6 +2779,21 @@ mod tests { append: false, }); assert!(matches!(app.blocks.last(), Some(Block::User(text)) if text == "change direction")); + + // Delivery is echoed with the accepted message ID and must update the + // optimistic block rather than rendering the steer twice. + app.apply(Update::UserMessage { + id: "injected-1".into(), + text: "change direction".into(), + append: false, + }); + assert_eq!( + app.blocks + .iter() + .filter(|block| matches!(block, Block::User(_))) + .count(), + 1 + ); assert!(app.working()); } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 451b48a..6e17e76 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -544,18 +544,26 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( )) .block_task() .await - .map(|_| ()) + .map(|response| Some(response.message_id)) } else { connection .send_request(wire::PromptRequest::new(session_id.clone(), blocks)) .block_task() .await - .map(|_| ()) + .map(|_| None) }; - if let Err(error) = outcome { - app.paste(&prompt.text); - app.restore_attachments(prompt.attachments); - app.note(format!("message was not accepted: {}", error.message)); + match outcome { + Ok(Some(message_id)) => app.apply(Update::UserMessage { + id: message_id.to_string(), + text: prompt.text, + append: false, + }), + 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) => { From 37a5685c6b2782a88819a698a52b2b85c3a66848 Mon Sep 17 00:00:00 2001 From: daniel Date: Wed, 26 Aug 2026 12:18:32 +0100 Subject: [PATCH 12/12] fix(tui): stage pending steers above input --- src/tui/app.rs | 52 ++++++++++++++++++++++--------- src/tui/mod.rs | 3 +- src/tui/ui.rs | 84 +++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 122 insertions(+), 17 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 7e8ce81..9302bbc 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -2,7 +2,7 @@ use std::{ cmp::Reverse, - collections::{BTreeSet, HashMap}, + collections::{BTreeSet, HashMap, VecDeque}, ops::Range, path::PathBuf, time::{Duration, Instant}, @@ -39,7 +39,9 @@ use super::{ pub enum Update { /// The actual dynamically allocated A2A listen address. A2aAddress(String), - /// A user message accepted or replayed by the agent. + /// 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, @@ -194,6 +196,12 @@ 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, @@ -402,6 +410,7 @@ pub struct App { pub phase: Phase, pub turn_started: 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, @@ -663,6 +672,7 @@ impl App { phase: Phase::Idle, turn_started: None, can_steer: false, + pending_steers: VecDeque::new(), message_blocks: HashMap::new(), agent_stream_sealed: false, latest_agent_source: String::new(), @@ -1118,6 +1128,7 @@ impl App { } 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; @@ -1169,7 +1180,22 @@ impl App { self.available_commands = commands; } } + 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.pending_steers.push_back(PendingSteer { id, text }); + } + } 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 } => { @@ -1455,6 +1481,7 @@ impl App { self.phase = Phase::Idle; self.turn_started = None; self.message_blocks.clear(); + self.pending_steers.clear(); self.compacting = false; self.usage = None; self.scroll = usize::MAX; @@ -2773,27 +2800,24 @@ mod tests { .any(|block| matches!(block, Block::User(_))) ); - app.apply(Update::UserMessage { + app.apply(Update::SteerAccepted { id: "injected-1".into(), text: prompt.text, - append: false, }); - assert!(matches!(app.blocks.last(), Some(Block::User(text)) if text == "change direction")); + assert_eq!(app.pending_steers.len(), 1); + assert!( + !app.blocks + .iter() + .any(|block| matches!(block, Block::User(_))) + ); - // Delivery is echoed with the accepted message ID and must update the - // optimistic block rather than rendering the steer twice. app.apply(Update::UserMessage { id: "injected-1".into(), text: "change direction".into(), append: false, }); - assert_eq!( - app.blocks - .iter() - .filter(|block| matches!(block, Block::User(_))) - .count(), - 1 - ); + assert!(app.pending_steers.is_empty()); + assert!(matches!(app.blocks.last(), Some(Block::User(text)) if text == "change direction")); assert!(app.working()); } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 6e17e76..f14b829 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -553,10 +553,9 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( .map(|_| None) }; match outcome { - Ok(Some(message_id)) => app.apply(Update::UserMessage { + Ok(Some(message_id)) => app.apply(Update::SteerAccepted { id: message_id.to_string(), text: prompt.text, - append: false, }), Ok(None) => {} Err(error) => { diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 073ba1d..ee4086e 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -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() { @@ -982,6 +986,38 @@ fn draw_logs(frame: &mut Frame<'_>, app: &App, area: Rect) { frame.render_widget(Paragraph::new(lines), inner); } +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()) @@ -1371,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(