From 0782d0f1ae0709058834eafdf2a4260fda6fa93d Mon Sep 17 00:00:00 2001 From: Samuel Galanakis Date: Mon, 20 Jul 2026 15:22:02 +0200 Subject: [PATCH 01/25] Update CLI to Lash alpha.104 Move the Lash SDK dependency graph to the crates.io release and adapt the CLI to the alpha.104 runtime contracts. Release-Notes: - Update lash-cli to Lash v0.1.0-alpha.104. --- Cargo.lock | 568 ++++++++++-------- Cargo.toml | 42 +- crates/lash-cli/src/app/live.rs | 2 + crates/lash-cli/src/app/mod.rs | 22 +- crates/lash-cli/src/app/runtime_events.rs | 126 +++- crates/lash-cli/src/app/tests/render.rs | 46 ++ .../lash-cli/src/app/tests/runtime_state.rs | 2 + crates/lash-cli/src/autonomous.rs | 2 + .../src/examples/mcp_discovery/resolver.rs | 56 +- crates/lash-cli/src/interactive/runtime.rs | 1 + crates/lash-cli/src/interactive/tests.rs | 1 + crates/lash-cli/src/turn_runner.rs | 1 + 12 files changed, 531 insertions(+), 338 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 61c450b..b88f64c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,7 +94,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -105,14 +105,14 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arboard" @@ -130,7 +130,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.60.2", "wl-clipboard-rs", "x11rb", ] @@ -158,13 +158,13 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.2", ] [[package]] @@ -181,9 +181,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.1" +version = "1.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" dependencies = [ "aws-lc-sys", "zeroize", @@ -191,9 +191,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" dependencies = [ "cc", "cmake", @@ -246,11 +246,11 @@ checksum = "5afee99ef5f7527f3944f2bf4f5d443749fa47d43eb1d4f83a36e839be7900a3" [[package]] name = "bindgen" -version = "0.70.1" +version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cexpr", "clang-sys", "itertools 0.13.0", @@ -259,9 +259,9 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash 1.1.0", + "rustc-hash 2.1.3", "shlex 1.3.0", - "syn", + "syn 2.0.119", ] [[package]] @@ -287,9 +287,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] @@ -319,9 +319,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.3" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" dependencies = [ "memchr", "serde_core", @@ -341,9 +341,9 @@ checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] name = "bytemuck" -version = "1.25.1" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -374,9 +374,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.67" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" dependencies = [ "find-msvc-tools", "jobserver", @@ -407,9 +407,9 @@ checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" @@ -449,9 +449,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" dependencies = [ "clap_builder", "clap_derive", @@ -459,9 +459,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" dependencies = [ "anstream", "anstyle", @@ -478,7 +478,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -647,7 +647,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "crossterm_winapi", "mio", "parking_lot", @@ -703,7 +703,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.119", ] [[package]] @@ -714,7 +714,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -756,7 +756,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -766,7 +766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn", + "syn 2.0.119", ] [[package]] @@ -835,7 +835,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -844,7 +844,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "objc2", ] @@ -856,7 +856,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -920,7 +920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -972,9 +972,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fax" @@ -993,9 +993,9 @@ dependencies = [ [[package]] name = "fff-grep" -version = "0.6.2-nightly.a677d64" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7012271523fc16ab6eabcf6911570ed29b0904b8889630396a1a3666b2994df1" +checksum = "88283e24a45f87707db72788a5e41d662df75d445e81fb34d197e04f2b2d443f" dependencies = [ "bstr", "memchr", @@ -1003,9 +1003,9 @@ dependencies = [ [[package]] name = "fff-notify-debouncer-full" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c6f0c16164d10c082af931377766f5495440bee66a9a841bbcea1af5de8878c" +checksum = "29a4ebea7b8a2840cd59358bbf396f6f04313ce8eae84ac79703ce80298b8731" dependencies = [ "file-id", "log", @@ -1017,9 +1017,9 @@ dependencies = [ [[package]] name = "fff-query-parser" -version = "0.6.2-nightly.a677d64" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb455721b327e8cbe86e011edc99d3ea0aa2089ddd8e1031e41ac7c565841de7" +checksum = "ab582f40d563f533ef3c7fe893fb936a42111ffb41517d3aa01a6e49dbdc1564" dependencies = [ "zlob", ] @@ -1060,7 +1060,7 @@ dependencies = [ "serde_json", "smallvec", "smartstring", - "thiserror 2.0.18", + "thiserror 2.0.19", "toml", "tracing", "tracing-appender", @@ -1155,9 +1155,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" dependencies = [ "futures-channel", "futures-core", @@ -1170,9 +1170,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", "futures-sink", @@ -1180,15 +1180,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -1197,38 +1197,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-channel", "futures-core", @@ -1321,7 +1321,7 @@ version = "0.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "libc", "libgit2-sys", "log", @@ -1342,9 +1342,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "globset" -version = "0.4.18" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" dependencies = [ "aho-corasick", "bstr", @@ -1419,7 +1419,7 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad82d6598ccf1dac15c8b758a1bd282b755b6776be600429176757190a1b0202" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "byteorder", "heed-traits", "heed-types", @@ -1465,7 +1465,7 @@ dependencies = [ "rand 0.9.5", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.19", "ureq", "windows-sys 0.60.2", ] @@ -1491,9 +1491,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -1501,9 +1501,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -1712,9 +1712,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.28" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2adf14691c72bcfc1058740436a35bdd3ae9c07d1a941ef00b749e9ea16aefa7" +checksum = "7b009b6744c1445efd7244084e25e498636412effb6760b55067553baa925cc7" dependencies = [ "crossbeam-deque", "globset", @@ -1769,7 +1769,7 @@ version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "inotify-sys", "libc", ] @@ -1840,7 +1840,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.19", "walkdir", "windows-link", ] @@ -1855,7 +1855,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.119", ] [[package]] @@ -1874,7 +1874,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1944,7 +1944,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "libc", ] @@ -2039,8 +2039,9 @@ dependencies = [ [[package]] name = "lash-core" -version = "0.0.0-dev" -source = "git+https://github.com/SamGalanakis/lash?rev=a4ba0f53c9043858af9c50001ec9629bbb43b3a7#a4ba0f53c9043858af9c50001ec9629bbb43b3a7" +version = "0.1.0-alpha.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7ceb8f5502c3b27f8d574e3fed08e11eaaf716b7d1e9a5bb4a7cd3bfbe02a57" dependencies = [ "arc-swap", "async-trait", @@ -2054,7 +2055,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tokio-util", "tracing", @@ -2106,21 +2107,23 @@ dependencies = [ [[package]] name = "lash-http-transport" -version = "0.0.0-dev" -source = "git+https://github.com/SamGalanakis/lash?rev=a4ba0f53c9043858af9c50001ec9629bbb43b3a7#a4ba0f53c9043858af9c50001ec9629bbb43b3a7" +version = "0.1.0-alpha.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5115730f688adfc1a45c09b721eedfcec26391a9d107cf9a0def494340b0140c" dependencies = [ "async-trait", "bytes", "lash-sansio", "reqwest 0.13.4", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", ] [[package]] name = "lash-lashlang-runtime" -version = "0.0.0-dev" -source = "git+https://github.com/SamGalanakis/lash?rev=a4ba0f53c9043858af9c50001ec9629bbb43b3a7#a4ba0f53c9043858af9c50001ec9629bbb43b3a7" +version = "0.1.0-alpha.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba0fc6a5b31149b043bfe31c785673bf809c9bb3c10a4d28811261518230c283" dependencies = [ "async-trait", "chrono", @@ -2139,8 +2142,9 @@ dependencies = [ [[package]] name = "lash-llm-tools" -version = "0.0.0-dev" -source = "git+https://github.com/SamGalanakis/lash?rev=a4ba0f53c9043858af9c50001ec9629bbb43b3a7#a4ba0f53c9043858af9c50001ec9629bbb43b3a7" +version = "0.1.0-alpha.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4813ff034e9534844562f2e1e6f8b0be0789545dd35394c338addcc4120937ce" dependencies = [ "async-trait", "jsonschema", @@ -2152,8 +2156,9 @@ dependencies = [ [[package]] name = "lash-llm-transport" -version = "0.0.0-dev" -source = "git+https://github.com/SamGalanakis/lash?rev=a4ba0f53c9043858af9c50001ec9629bbb43b3a7#a4ba0f53c9043858af9c50001ec9629bbb43b3a7" +version = "0.1.0-alpha.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f335ddfb08c0515798541a873a239ee852b23b98412b7a796c3fa92962d9d00" dependencies = [ "async-trait", "bytes", @@ -2166,8 +2171,9 @@ dependencies = [ [[package]] name = "lash-plugin-mcp" -version = "0.0.0-dev" -source = "git+https://github.com/SamGalanakis/lash?rev=a4ba0f53c9043858af9c50001ec9629bbb43b3a7#a4ba0f53c9043858af9c50001ec9629bbb43b3a7" +version = "0.1.0-alpha.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07b2123a8ef4a8c785cdb2ff3a691b48231143c0a7d925eb129adc191baf7e9b" dependencies = [ "async-trait", "base64 0.22.1", @@ -2178,15 +2184,16 @@ dependencies = [ "rmcp", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tracing", ] [[package]] name = "lash-plugin-observational-memory" -version = "0.0.0-dev" -source = "git+https://github.com/SamGalanakis/lash?rev=a4ba0f53c9043858af9c50001ec9629bbb43b3a7#a4ba0f53c9043858af9c50001ec9629bbb43b3a7" +version = "0.1.0-alpha.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "781703da17981d68037adcd860e2d64c4fd5020a42e7106b1369eb1cdb886dd5" dependencies = [ "async-trait", "chrono", @@ -2197,8 +2204,9 @@ dependencies = [ [[package]] name = "lash-plugin-plan-mode" -version = "0.0.0-dev" -source = "git+https://github.com/SamGalanakis/lash?rev=a4ba0f53c9043858af9c50001ec9629bbb43b3a7#a4ba0f53c9043858af9c50001ec9629bbb43b3a7" +version = "0.1.0-alpha.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c929e4b0f7b4b23ed5eb2fbee4f87402ec68a9b010e1a8c3dd19a29c5e472e0d" dependencies = [ "async-trait", "lash-core", @@ -2212,8 +2220,9 @@ dependencies = [ [[package]] name = "lash-plugin-process-controls" -version = "0.0.0-dev" -source = "git+https://github.com/SamGalanakis/lash?rev=a4ba0f53c9043858af9c50001ec9629bbb43b3a7#a4ba0f53c9043858af9c50001ec9629bbb43b3a7" +version = "0.1.0-alpha.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ff6f24549ec29a7a83a2dcdc1cd1046fa582eacd2e1d8a907a5cc7ba55dae3f" dependencies = [ "async-trait", "lash-core", @@ -2224,8 +2233,9 @@ dependencies = [ [[package]] name = "lash-plugin-tool-output-budget" -version = "0.0.0-dev" -source = "git+https://github.com/SamGalanakis/lash?rev=a4ba0f53c9043858af9c50001ec9629bbb43b3a7#a4ba0f53c9043858af9c50001ec9629bbb43b3a7" +version = "0.1.0-alpha.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1616df8ef28c48a00d9d54c0947bc93ade4102753ecfcc3ff4f6092ba9f30e46" dependencies = [ "lash-core", "serde", @@ -2235,8 +2245,9 @@ dependencies = [ [[package]] name = "lash-protocol-rlm" -version = "0.0.0-dev" -source = "git+https://github.com/SamGalanakis/lash?rev=a4ba0f53c9043858af9c50001ec9629bbb43b3a7#a4ba0f53c9043858af9c50001ec9629bbb43b3a7" +version = "0.1.0-alpha.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc9224e1473a4a209927f94a6115b9d336c833bf42156eaa1d8ebec07078524f" dependencies = [ "async-trait", "jsonschema", @@ -2253,8 +2264,9 @@ dependencies = [ [[package]] name = "lash-protocol-standard" -version = "0.0.0-dev" -source = "git+https://github.com/SamGalanakis/lash?rev=a4ba0f53c9043858af9c50001ec9629bbb43b3a7#a4ba0f53c9043858af9c50001ec9629bbb43b3a7" +version = "0.1.0-alpha.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce005b8e71f9149f0ca284bda46a4ae89a66270a9902e92112a2820e62e447fb" dependencies = [ "async-trait", "lash-core", @@ -2264,8 +2276,9 @@ dependencies = [ [[package]] name = "lash-provider-anthropic" -version = "0.0.0-dev" -source = "git+https://github.com/SamGalanakis/lash?rev=a4ba0f53c9043858af9c50001ec9629bbb43b3a7#a4ba0f53c9043858af9c50001ec9629bbb43b3a7" +version = "0.1.0-alpha.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b8724c8ec9ec4c6742944e210639a521249307b7f7b7cdb1c823dd4f9d48e74" dependencies = [ "async-trait", "base64 0.22.1", @@ -2279,8 +2292,9 @@ dependencies = [ [[package]] name = "lash-provider-auth" -version = "0.0.0-dev" -source = "git+https://github.com/SamGalanakis/lash?rev=a4ba0f53c9043858af9c50001ec9629bbb43b3a7#a4ba0f53c9043858af9c50001ec9629bbb43b3a7" +version = "0.1.0-alpha.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0dad37b4dee717f3fe597079ea8d8dd72c96dd14616cb96e6cca1e86336099e" dependencies = [ "async-trait", "base64 0.22.1", @@ -2288,15 +2302,16 @@ dependencies = [ "reqwest 0.13.4", "serde_json", "sha2", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "uuid", ] [[package]] name = "lash-provider-google" -version = "0.0.0-dev" -source = "git+https://github.com/SamGalanakis/lash?rev=a4ba0f53c9043858af9c50001ec9629bbb43b3a7#a4ba0f53c9043858af9c50001ec9629bbb43b3a7" +version = "0.1.0-alpha.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c04c965227406c69831bd37b67a8b0287d6a5efa9497451260e491aa95fcd5fa" dependencies = [ "async-trait", "base64 0.22.1", @@ -2313,8 +2328,9 @@ dependencies = [ [[package]] name = "lash-provider-openai" -version = "0.0.0-dev" -source = "git+https://github.com/SamGalanakis/lash?rev=a4ba0f53c9043858af9c50001ec9629bbb43b3a7#a4ba0f53c9043858af9c50001ec9629bbb43b3a7" +version = "0.1.0-alpha.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07664f5817cdd3f18965a57734c19a9f31a5e63097f116f910cae41e892c3c4c" dependencies = [ "async-trait", "base64 0.22.1", @@ -2334,21 +2350,23 @@ dependencies = [ [[package]] name = "lash-remote-protocol" -version = "0.0.0-dev" -source = "git+https://github.com/SamGalanakis/lash?rev=a4ba0f53c9043858af9c50001ec9629bbb43b3a7#a4ba0f53c9043858af9c50001ec9629bbb43b3a7" +version = "0.1.0-alpha.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361070138fc7b1dd3156f69ae2c9600f66facc520af9932e1430d71bdcd6af87" dependencies = [ "base64 0.22.1", "lash-core", "schemars", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] name = "lash-rlm-types" -version = "0.0.0-dev" -source = "git+https://github.com/SamGalanakis/lash?rev=a4ba0f53c9043858af9c50001ec9629bbb43b3a7#a4ba0f53c9043858af9c50001ec9629bbb43b3a7" +version = "0.1.0-alpha.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e87ac6d7bde49f4f14a8b446b2b1cf4f8453af1b838b9f2b6994220c2c8b88" dependencies = [ "lash-sansio", "serde", @@ -2357,8 +2375,9 @@ dependencies = [ [[package]] name = "lash-runtime" -version = "0.0.0-dev" -source = "git+https://github.com/SamGalanakis/lash?rev=a4ba0f53c9043858af9c50001ec9629bbb43b3a7#a4ba0f53c9043858af9c50001ec9629bbb43b3a7" +version = "0.1.0-alpha.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd806acfa9fa6e563c68a204e1d9d04b1a5facea7050e5d833e12814b99746c" dependencies = [ "async-trait", "futures-util", @@ -2374,15 +2393,16 @@ dependencies = [ "lashlang", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tokio-util", ] [[package]] name = "lash-sansio" -version = "0.0.0-dev" -source = "git+https://github.com/SamGalanakis/lash?rev=a4ba0f53c9043858af9c50001ec9629bbb43b3a7#a4ba0f53c9043858af9c50001ec9629bbb43b3a7" +version = "0.1.0-alpha.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d40c47524b24cd92459545cd8725babc0f9007717bcdb8e7b302e0148ecd3fd" dependencies = [ "schemars", "serde", @@ -2409,8 +2429,9 @@ dependencies = [ [[package]] name = "lash-sqlite-store" -version = "0.0.0-dev" -source = "git+https://github.com/SamGalanakis/lash?rev=a4ba0f53c9043858af9c50001ec9629bbb43b3a7#a4ba0f53c9043858af9c50001ec9629bbb43b3a7" +version = "0.1.0-alpha.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db6026be31fdfa2f77b4babd2d9e85501ff926e9764199f1009ef0d6ec973b4" dependencies = [ "async-trait", "flate2", @@ -2429,8 +2450,9 @@ dependencies = [ [[package]] name = "lash-standard-plugins" -version = "0.0.0-dev" -source = "git+https://github.com/SamGalanakis/lash?rev=a4ba0f53c9043858af9c50001ec9629bbb43b3a7#a4ba0f53c9043858af9c50001ec9629bbb43b3a7" +version = "0.1.0-alpha.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92724f30e1a7f1636395040c16316dc59b2ba12d97b2b60272cf585f65ae37f4" dependencies = [ "async-trait", "lash-core", @@ -2443,8 +2465,9 @@ dependencies = [ [[package]] name = "lash-subagents" -version = "0.0.0-dev" -source = "git+https://github.com/SamGalanakis/lash?rev=a4ba0f53c9043858af9c50001ec9629bbb43b3a7#a4ba0f53c9043858af9c50001ec9629bbb43b3a7" +version = "0.1.0-alpha.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61978b610a55ee2f6e5fe892ad5240d8fd72db12ffac0f2d318fbb9e3a1322e2" dependencies = [ "async-trait", "lash-core", @@ -2459,8 +2482,9 @@ dependencies = [ [[package]] name = "lash-tool-support" -version = "0.0.0-dev" -source = "git+https://github.com/SamGalanakis/lash?rev=a4ba0f53c9043858af9c50001ec9629bbb43b3a7#a4ba0f53c9043858af9c50001ec9629bbb43b3a7" +version = "0.1.0-alpha.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a4b6461a5bd6f05c0099bff427bd7dc049319168897500acd397f8beff7c5b9" dependencies = [ "async-trait", "ignore", @@ -2475,8 +2499,9 @@ dependencies = [ [[package]] name = "lash-tools" -version = "0.0.0-dev" -source = "git+https://github.com/SamGalanakis/lash?rev=a4ba0f53c9043858af9c50001ec9629bbb43b3a7#a4ba0f53c9043858af9c50001ec9629bbb43b3a7" +version = "0.1.0-alpha.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38759ff575c98ea7dd8d013faa647200d1acff85e6e65fcfffa606cbbcfcf02e" dependencies = [ "async-trait", "globset", @@ -2497,14 +2522,15 @@ dependencies = [ [[package]] name = "lash-trace" -version = "0.0.0-dev" -source = "git+https://github.com/SamGalanakis/lash?rev=a4ba0f53c9043858af9c50001ec9629bbb43b3a7#a4ba0f53c9043858af9c50001ec9629bbb43b3a7" +version = "0.1.0-alpha.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0345917d20dc547d06fdf0b37879dce899e9716876e799a185dc80d6674776a" dependencies = [ "chrono", "serde", "serde_json", "sha2", - "thiserror 2.0.18", + "thiserror 2.0.19", "uuid", ] @@ -2529,8 +2555,9 @@ dependencies = [ [[package]] name = "lashlang" -version = "0.0.0-dev" -source = "git+https://github.com/SamGalanakis/lash?rev=a4ba0f53c9043858af9c50001ec9629bbb43b3a7#a4ba0f53c9043858af9c50001ec9629bbb43b3a7" +version = "0.1.0-alpha.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2672f55899fe02403e40d01d04507eccc73b55042be91bde41750767801623f7" dependencies = [ "async-trait", "compact_str", @@ -2540,7 +2567,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -2704,9 +2731,9 @@ dependencies = [ [[package]] name = "matrixmultiply" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" dependencies = [ "autocfg", "rawpointer", @@ -2761,9 +2788,9 @@ checksum = "c505b3e17ed6b70a7ed2e67fbb2c560ee327353556120d6e72f5232b6880d536" [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "log", @@ -2806,7 +2833,7 @@ checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2848,7 +2875,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases 0.1.1", "libc", @@ -2860,9 +2887,9 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg-if", - "cfg_aliases 0.2.1", + "cfg_aliases 0.2.2", "libc", ] @@ -2891,7 +2918,7 @@ version = "9.0.0-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b44b771d4dd781ef14c84078693e67495da6b47f609f72e8a4da8420a861240e" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "inotify", "kqueue", "libc", @@ -2911,7 +2938,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] @@ -2920,7 +2947,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3049,7 +3076,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "objc2", "objc2-core-graphics", "objc2-foundation", @@ -3061,7 +3088,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "dispatch2", "objc2", ] @@ -3072,7 +3099,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "dispatch2", "objc2", "objc2-core-foundation", @@ -3101,7 +3128,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "objc2", "objc2-core-foundation", ] @@ -3112,7 +3139,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "objc2", "objc2-core-foundation", ] @@ -3144,7 +3171,7 @@ version = "6.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "libc", "once_cell", "onig_sys", @@ -3179,7 +3206,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3289,7 +3316,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3319,7 +3346,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", @@ -3334,9 +3361,9 @@ checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6" [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" [[package]] name = "portable-pty" @@ -3396,14 +3423,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.119", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -3428,7 +3455,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "getopts", "memchr", "pulldown-cmark-escape", @@ -3469,14 +3496,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", - "cfg_aliases 0.2.1", + "cfg_aliases 0.2.2", "pin-project-lite", "quinn-proto", "quinn-udp", "rustc-hash 2.1.3", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tracing", "web-time", @@ -3499,7 +3526,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.19", "tinyvec", "tracing", "web-time", @@ -3511,19 +3538,19 @@ version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ - "cfg_aliases 0.2.1", + "cfg_aliases 0.2.2", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -3622,7 +3649,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] @@ -3668,7 +3695,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] @@ -3690,14 +3717,14 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] name = "regex" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -3707,9 +3734,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -3828,7 +3855,7 @@ dependencies = [ "serde", "serde_json", "sse-stream", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tokio-stream", "tokio-util", @@ -3860,7 +3887,7 @@ version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -3901,7 +3928,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -3914,18 +3941,18 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "aws-lc-rs", "log", @@ -3977,7 +4004,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4059,7 +4086,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 2.0.119", ] [[package]] @@ -4074,7 +4101,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "core-foundation", "core-foundation-sys", "libc", @@ -4099,9 +4126,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -4109,22 +4136,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.2", ] [[package]] @@ -4135,14 +4162,14 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -4275,15 +4302,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -4333,12 +4360,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4409,9 +4436,20 @@ checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" dependencies = [ "proc-macro2", "quote", @@ -4444,7 +4482,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4457,7 +4495,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4471,11 +4509,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.19", ] [[package]] @@ -4486,18 +4524,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.2", ] [[package]] @@ -4531,9 +4569,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.53" +version = "0.3.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" dependencies = [ "deranged", "num-conv", @@ -4551,9 +4589,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.31" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -4613,7 +4651,7 @@ dependencies = [ "serde", "serde_json", "spm_precompiled", - "thiserror 2.0.18", + "thiserror 2.0.19", "unicode-normalization-alignments", "unicode-segmentation", "unicode_categories", @@ -4621,9 +4659,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" dependencies = [ "bytes", "libc", @@ -4638,13 +4676,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4771,7 +4809,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -4814,7 +4852,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "symlink", - "thiserror 2.0.18", + "thiserror 2.0.19", "time", "tracing-subscriber", ] @@ -4827,7 +4865,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4914,7 +4952,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -5031,9 +5069,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.5" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -5155,7 +5193,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -5200,7 +5238,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "rustix 1.1.4", "wayland-backend", "wayland-scanner", @@ -5212,7 +5250,7 @@ version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-scanner", @@ -5224,7 +5262,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -5273,9 +5311,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] @@ -5286,14 +5324,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.8", + "webpki-roots 1.0.9", ] [[package]] name = "webpki-roots" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -5326,7 +5364,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5388,7 +5426,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -5399,7 +5437,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -5710,7 +5748,7 @@ dependencies = [ "log", "os_pipe", "rustix 1.1.4", - "thiserror 2.0.18", + "thiserror 2.0.19", "tree_magic_mini", "wayland-backend", "wayland-client", @@ -5743,9 +5781,9 @@ checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] name = "xxhash-rust" -version = "0.8.16" +version = "0.8.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d93c89cdc2d3a63c3ec48ffe926931bdc069eafa8e4402fe6d8f790c9d1e576" +checksum = "985eec839aaf2a1270af8f4ebcf63cf9401cfd90f0902f97c28d9f104ffbde72" [[package]] name = "yoke" @@ -5766,7 +5804,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -5787,7 +5825,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -5807,7 +5845,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -5847,24 +5885,24 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "zlob" -version = "1.3.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07934fb13f0f4e14281bab6b0e984cfc03891d2cf5b0a8bd0ab14fd240e21106" +checksum = "e41cb327ac1b7e7e0d4514658500cb5734cd655edbe4e5ffeda69955da9028ee" dependencies = [ "bindgen", - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zune-core" diff --git a/Cargo.toml b/Cargo.toml index 556fe99..8fd7b19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,28 +47,28 @@ tokio-util = "0.7" tracing = "0.1" uuid = "1" -# The CLI tracks one reviewed Lash revision. Dependency updates are explicit, +# The CLI tracks one reviewed Lash release. Dependency updates are explicit, # testable compatibility changes rather than an implicit lockstep workspace. -lash = { package = "lash-runtime", git = "https://github.com/SamGalanakis/lash", rev = "a4ba0f53c9043858af9c50001ec9629bbb43b3a7" } -lash-core = { git = "https://github.com/SamGalanakis/lash", rev = "a4ba0f53c9043858af9c50001ec9629bbb43b3a7" } -lash-lashlang-runtime = { git = "https://github.com/SamGalanakis/lash", rev = "a4ba0f53c9043858af9c50001ec9629bbb43b3a7" } -lash-llm-tools = { git = "https://github.com/SamGalanakis/lash", rev = "a4ba0f53c9043858af9c50001ec9629bbb43b3a7" } -lash-plugin-mcp = { git = "https://github.com/SamGalanakis/lash", rev = "a4ba0f53c9043858af9c50001ec9629bbb43b3a7" } -lash-plugin-plan-mode = { git = "https://github.com/SamGalanakis/lash", rev = "a4ba0f53c9043858af9c50001ec9629bbb43b3a7" } -lash-plugin-process-controls = { git = "https://github.com/SamGalanakis/lash", rev = "a4ba0f53c9043858af9c50001ec9629bbb43b3a7" } -lash-protocol-rlm = { git = "https://github.com/SamGalanakis/lash", rev = "a4ba0f53c9043858af9c50001ec9629bbb43b3a7" } -lash-protocol-standard = { git = "https://github.com/SamGalanakis/lash", rev = "a4ba0f53c9043858af9c50001ec9629bbb43b3a7" } -lash-provider-anthropic = { git = "https://github.com/SamGalanakis/lash", rev = "a4ba0f53c9043858af9c50001ec9629bbb43b3a7" } -lash-provider-auth = { git = "https://github.com/SamGalanakis/lash", rev = "a4ba0f53c9043858af9c50001ec9629bbb43b3a7" } -lash-provider-google = { git = "https://github.com/SamGalanakis/lash", rev = "a4ba0f53c9043858af9c50001ec9629bbb43b3a7" } -lash-provider-openai = { git = "https://github.com/SamGalanakis/lash", rev = "a4ba0f53c9043858af9c50001ec9629bbb43b3a7" } -lash-rlm-types = { git = "https://github.com/SamGalanakis/lash", rev = "a4ba0f53c9043858af9c50001ec9629bbb43b3a7" } -lash-sqlite-store = { git = "https://github.com/SamGalanakis/lash", rev = "a4ba0f53c9043858af9c50001ec9629bbb43b3a7" } -lash-standard-plugins = { git = "https://github.com/SamGalanakis/lash", rev = "a4ba0f53c9043858af9c50001ec9629bbb43b3a7" } -lash-subagents = { git = "https://github.com/SamGalanakis/lash", rev = "a4ba0f53c9043858af9c50001ec9629bbb43b3a7" } -lash-tool-support = { git = "https://github.com/SamGalanakis/lash", rev = "a4ba0f53c9043858af9c50001ec9629bbb43b3a7" } -lash-trace = { git = "https://github.com/SamGalanakis/lash", rev = "a4ba0f53c9043858af9c50001ec9629bbb43b3a7" } -lashlang = { git = "https://github.com/SamGalanakis/lash", rev = "a4ba0f53c9043858af9c50001ec9629bbb43b3a7" } +lash = { package = "lash-runtime", version = "0.1.0-alpha.104" } +lash-core = { version = "0.1.0-alpha.104" } +lash-lashlang-runtime = { version = "0.1.0-alpha.104" } +lash-llm-tools = { version = "0.1.0-alpha.104" } +lash-plugin-mcp = { version = "0.1.0-alpha.104" } +lash-plugin-plan-mode = { version = "0.1.0-alpha.104" } +lash-plugin-process-controls = { version = "0.1.0-alpha.104" } +lash-protocol-rlm = { version = "0.1.0-alpha.104" } +lash-protocol-standard = { version = "0.1.0-alpha.104" } +lash-provider-anthropic = { version = "0.1.0-alpha.104" } +lash-provider-auth = { version = "0.1.0-alpha.104" } +lash-provider-google = { version = "0.1.0-alpha.104" } +lash-provider-openai = { version = "0.1.0-alpha.104" } +lash-rlm-types = { version = "0.1.0-alpha.104" } +lash-sqlite-store = { version = "0.1.0-alpha.104" } +lash-standard-plugins = { version = "0.1.0-alpha.104" } +lash-subagents = { version = "0.1.0-alpha.104" } +lash-tool-support = { version = "0.1.0-alpha.104" } +lash-trace = { version = "0.1.0-alpha.104" } +lashlang = { version = "0.1.0-alpha.104" } lash-autoresearch = { path = "crates/lash-autoresearch" } lash-cli-perf = { path = "crates/lash-cli-perf" } diff --git a/crates/lash-cli/src/app/live.rs b/crates/lash-cli/src/app/live.rs index 2286329..a21b0a3 100644 --- a/crates/lash-cli/src/app/live.rs +++ b/crates/lash-cli/src/app/live.rs @@ -156,6 +156,8 @@ impl App { self.iteration = 0; self.live.assistant.clear(); self.live.reasoning.clear(); + self.live.model_output_chunks.clear(); + self.live.model_output_timeline_start = self.timeline.len(); self.clear_live_tool_output(); self.usage.live_output_chars_estimate = 0; self.usage.live_output_tokens_estimate = 0; diff --git a/crates/lash-cli/src/app/mod.rs b/crates/lash-cli/src/app/mod.rs index 84dc094..4aa6de9 100644 --- a/crates/lash-cli/src/app/mod.rs +++ b/crates/lash-cli/src/app/mod.rs @@ -12,7 +12,7 @@ use std::sync::{Arc, Mutex}; use lash_core::runtime::PendingTurnInput; use lash_core::{ Message, MessageRole, PartKind, PluginMessage, ProcessHandleSummary, ProcessLifecycleStatus, - PromptUsage, SessionProcessEventKind, TokenUsage, ToolCallRecord, + PromptUsage, SessionProcessEventKind, TokenUsage, ToolCallRecord, TurnActivityId, }; use lash_tui::{Line, Rect}; use lash_tui_extensions::TuiExtensions; @@ -533,6 +533,24 @@ pub struct LiveTurnView { pub reasoning: LiveMarkdown, /// Live tool output preview anchored to the active tool activity block. pub tool_output: LiveToolOutput, + /// Provider-output chunks for the current model request. Correlation ids + /// let a retry retract only the failed attempt's streamed text. + model_output_chunks: Vec, + /// Timeline boundary before the current request began rendering output. + model_output_timeline_start: usize, +} + +#[derive(Clone, Copy)] +enum ModelOutputLane { + Assistant, + Reasoning, +} + +#[derive(Clone)] +struct ModelOutputChunk { + correlation_id: TurnActivityId, + lane: ModelOutputLane, + text: String, } impl Default for LiveTurnView { @@ -542,6 +560,8 @@ impl Default for LiveTurnView { assistant: LiveMarkdown::new(crate::assistant_text::MarkdownLane::Assistant), reasoning: LiveMarkdown::new(crate::assistant_text::MarkdownLane::Reasoning), tool_output: LiveToolOutput::default(), + model_output_chunks: Vec::new(), + model_output_timeline_start: 0, } } } diff --git a/crates/lash-cli/src/app/runtime_events.rs b/crates/lash-cli/src/app/runtime_events.rs index 4c3b659..4983739 100644 --- a/crates/lash-cli/src/app/runtime_events.rs +++ b/crates/lash-cli/src/app/runtime_events.rs @@ -22,6 +22,82 @@ fn runtime_status_from_plugin_event( } impl App { + fn append_model_output(&mut self, lane: ModelOutputLane, text: &str) { + match lane { + ModelOutputLane::Reasoning => { + if text.is_empty() { + return; + } + self.mark_first_token_arrived(); + if self.live.assistant.has_renderable_output() { + self.commit_live_assistant_block(); + } + let had_output = self.live.reasoning.has_renderable_output(); + self.live.reasoning.append(text); + if !had_output && self.live.reasoning.has_renderable_output() { + self.mark_visible_output(); + } + self.mark_visible_output(); + self.scroll_to_bottom(); + } + ModelOutputLane::Assistant => { + self.mark_first_token_arrived(); + self.usage.live_output_chars_estimate += text.chars().count() as i64; + self.usage.live_output_tokens_estimate = + live::estimate_tokens_from_char_count(self.usage.live_output_chars_estimate); + if self.live.reasoning.has_renderable_output() { + self.commit_live_reasoning_block(); + } + let had_output = self.live.assistant.has_renderable_output(); + self.live.assistant.append(text); + if !had_output && self.live.assistant.has_renderable_output() { + self.mark_visible_output(); + } + self.scroll_to_bottom(); + } + } + } + + fn reset_model_attempt_output( + &mut self, + assistant_prose_correlation_ids: &[TurnActivityId], + reasoning_correlation_ids: &[TurnActivityId], + ) { + let matches_reset = |chunk: &ModelOutputChunk| match chunk.lane { + ModelOutputLane::Assistant => { + assistant_prose_correlation_ids.contains(&chunk.correlation_id) + } + ModelOutputLane::Reasoning => reasoning_correlation_ids.contains(&chunk.correlation_id), + }; + if !self.live.model_output_chunks.iter().any(matches_reset) { + return; + } + + let retained = self + .live + .model_output_chunks + .iter() + .filter(|chunk| !matches_reset(chunk)) + .cloned() + .collect::>(); + self.timeline.truncate( + self.live + .model_output_timeline_start + .min(self.timeline.len()), + ); + self.live.assistant.clear(); + self.live.reasoning.clear(); + self.usage.live_output_chars_estimate = 0; + self.usage.live_output_tokens_estimate = 0; + self.invalidate_height_cache(); + + for chunk in &retained { + self.append_model_output(chunk.lane, &chunk.text); + } + self.live.model_output_chunks = retained; + self.scroll_to_bottom(); + } + fn activity_renders_prompt_response_inline(activity: &ActivityBlock) -> bool { matches!( activity.result.artifact, @@ -155,35 +231,29 @@ impl App { pub fn handle_turn_activity(&mut self, activity: TurnActivity) { match activity.event { TurnEvent::ReasoningDelta { text } => { - if text.is_empty() { - return; - } - self.mark_first_token_arrived(); - if self.live.assistant.has_renderable_output() { - self.commit_live_assistant_block(); - } - let had_output = self.live.reasoning.has_renderable_output(); - self.live.reasoning.append(&text); - if !had_output && self.live.reasoning.has_renderable_output() { - self.mark_visible_output(); - } - self.mark_visible_output(); - self.scroll_to_bottom(); + self.live.model_output_chunks.push(ModelOutputChunk { + correlation_id: activity.correlation_id, + lane: ModelOutputLane::Reasoning, + text: text.clone(), + }); + self.append_model_output(ModelOutputLane::Reasoning, &text); } TurnEvent::AssistantProseDelta { text } => { - self.mark_first_token_arrived(); - self.usage.live_output_chars_estimate += text.chars().count() as i64; - self.usage.live_output_tokens_estimate = - live::estimate_tokens_from_char_count(self.usage.live_output_chars_estimate); - if self.live.reasoning.has_renderable_output() { - self.commit_live_reasoning_block(); - } - let had_output = self.live.assistant.has_renderable_output(); - self.live.assistant.append(&text); - if !had_output && self.live.assistant.has_renderable_output() { - self.mark_visible_output(); - } - self.scroll_to_bottom(); + self.live.model_output_chunks.push(ModelOutputChunk { + correlation_id: activity.correlation_id, + lane: ModelOutputLane::Assistant, + text: text.clone(), + }); + self.append_model_output(ModelOutputLane::Assistant, &text); + } + TurnEvent::ModelAttemptReset { + assistant_prose_correlation_ids, + reasoning_correlation_ids, + } => { + self.reset_model_attempt_output( + &assistant_prose_correlation_ids, + &reasoning_correlation_ids, + ); } TurnEvent::FinalValue { value } => { self.finalize_live_markdown(); @@ -299,6 +369,8 @@ impl App { } TurnEvent::ModelRequestStarted { protocol_iteration } => { self.finalize_live_markdown(); + self.live.model_output_chunks.clear(); + self.live.model_output_timeline_start = self.timeline.len(); self.iteration = protocol_iteration + 1; if let Some(detail) = self.pending_retry_status.take() { self.set_status(CliRunState::Waiting, Some(detail), true); diff --git a/crates/lash-cli/src/app/tests/render.rs b/crates/lash-cli/src/app/tests/render.rs index 15d61ca..cc34a28 100644 --- a/crates/lash-cli/src/app/tests/render.rs +++ b/crates/lash-cli/src/app/tests/render.rs @@ -278,6 +278,52 @@ fn text_delta_stays_in_live_assistant_until_committed() { ); } +#[test] +fn model_attempt_reset_retracts_failed_streamed_output() { + let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); + app.start_turn(); + app.handle_turn_activity(TurnActivity::independent(TurnEvent::ModelRequestStarted { + protocol_iteration: 0, + })); + app.handle_turn_activity(TurnActivity::new( + lash_core::TurnActivityId::new("failed-reasoning"), + TurnEvent::ReasoningDelta { + text: "discarded reasoning".into(), + }, + )); + app.handle_turn_activity(TurnActivity::new( + lash_core::TurnActivityId::new("failed-prose"), + TurnEvent::AssistantProseDelta { + text: "discarded answer".into(), + }, + )); + + assert!(matches!( + app.timeline.last(), + Some(UiTimelineItem::AssistantReasoning(text)) if text == "discarded reasoning" + )); + app.handle_turn_activity(TurnActivity::independent(TurnEvent::ModelAttemptReset { + assistant_prose_correlation_ids: vec![lash_core::TurnActivityId::new("failed-prose")], + reasoning_correlation_ids: vec![lash_core::TurnActivityId::new("failed-reasoning")], + })); + + assert!(app.timeline.is_empty()); + assert!(app.live.assistant.normalized_text().is_none()); + assert!(app.live.reasoning.normalized_text().is_none()); + assert_eq!(app.usage.live_output_tokens_estimate, 0); + + app.handle_turn_activity(TurnActivity::new( + lash_core::TurnActivityId::new("successful-prose"), + TurnEvent::AssistantProseDelta { + text: "final answer".into(), + }, + )); + assert_eq!( + app.live.assistant.normalized_text().as_deref(), + Some("final answer") + ); +} + #[test] fn text_delta_updates_live_token_estimate() { let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); diff --git a/crates/lash-cli/src/app/tests/runtime_state.rs b/crates/lash-cli/src/app/tests/runtime_state.rs index ad71e68..cd50924 100644 --- a/crates/lash-cli/src/app/tests/runtime_state.rs +++ b/crates/lash-cli/src/app/tests/runtime_state.rs @@ -504,6 +504,7 @@ fn accepted_injected_turn_input_without_pending_match_still_renders_once() { inputs: vec![lash_core::AcceptedInjectedTurnInput { id: None, message: PluginMessage { + id: None, role: MessageRole::User, content: "runtime content".into(), origin: None, @@ -516,6 +517,7 @@ fn accepted_injected_turn_input_without_pending_match_still_renders_once() { app.handle_session_event(SessionStreamEvent::InjectedMessagesCommitted { messages: vec![PluginMessage { + id: None, role: MessageRole::User, content: "runtime content".into(), origin: None, diff --git a/crates/lash-cli/src/autonomous.rs b/crates/lash-cli/src/autonomous.rs index e9bb07e..fc0bf97 100644 --- a/crates/lash-cli/src/autonomous.rs +++ b/crates/lash-cli/src/autonomous.rs @@ -112,6 +112,7 @@ impl AutonomousRenderer { // aligned with the model's final answer. TurnEvent::ReasoningDelta { .. } => {} TurnEvent::ToolCallStarted { .. } + | TurnEvent::ModelAttemptReset { .. } | TurnEvent::QueuedWorkStarted { .. } | TurnEvent::Usage { .. } | TurnEvent::ChildUsage { .. } @@ -706,6 +707,7 @@ mod tests { outcome: TurnOutcome::Finished(TurnFinish::AssistantMessage { text: "hello".to_string(), }), + cancellation: None, assistant_output: AssistantOutput { safe_text: "hello".to_string(), raw_text: "hello".to_string(), diff --git a/crates/lash-cli/src/examples/mcp_discovery/resolver.rs b/crates/lash-cli/src/examples/mcp_discovery/resolver.rs index f0de6d5..8da6354 100644 --- a/crates/lash-cli/src/examples/mcp_discovery/resolver.rs +++ b/crates/lash-cli/src/examples/mcp_discovery/resolver.rs @@ -8,7 +8,7 @@ //! back to the originating MCP server. It resolves on demand only — it does not //! enumerate, advertise, or rank. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use async_trait::async_trait; use lash_core::ToolDefinition; @@ -47,26 +47,32 @@ impl McpDeferredToolResolver { #[async_trait] impl DeferredToolResolver for McpDeferredToolResolver { - async fn resolve(&self, path: &str) -> Resolution { - match self.by_call_path.get(path) { - Some(tool) => { - // The Tool Execution Binding records the routing the host needs - // to fulfil and to rebuild the grant on replay/recovery: which - // MCP server backs this tool and which underlying tool id to - // call. - let execution_binding = json!({ - "kind": "mcp", - "server": tool.server, - "tool_id": tool.definition.manifest.id.to_string(), - }); - Resolution::Resolved(Box::new( - ToolGrant::new(tool.definition.clone()) - .with_source_id(lash::tools::PLUGIN_TOOL_SOURCE_ID) - .with_execution_binding(execution_binding), - )) - } - None => Resolution::NotAvailable, - } + async fn resolve(&self, paths: &[&str]) -> BTreeMap { + paths + .iter() + .map(|path| { + let resolution = match self.by_call_path.get(*path) { + Some(tool) => { + // The Tool Execution Binding records the routing the host needs + // to fulfil and to rebuild the grant on replay/recovery: which + // MCP server backs this tool and which underlying tool id to + // call. + let execution_binding = json!({ + "kind": "mcp", + "server": tool.server, + "tool_id": tool.definition.manifest.id.to_string(), + }); + Resolution::Resolved(Box::new( + ToolGrant::new(tool.definition.clone()) + .with_source_id(lash::tools::PLUGIN_TOOL_SOURCE_ID) + .with_execution_binding(execution_binding), + )) + } + None => Resolution::NotAvailable, + }; + ((*path).to_string(), resolution) + }) + .collect() } } @@ -95,7 +101,8 @@ mod tests { async fn resolves_known_call_path_with_execution_binding() { let resolver = McpDeferredToolResolver::new([mcp_tool("appworld", "venmo_send", "venmo", "send")]); - let resolution = resolver.resolve("venmo.send").await; + let mut resolutions = resolver.resolve(&["venmo.send"]).await; + let resolution = resolutions.remove("venmo.send").unwrap(); let Resolution::Resolved(grant) = resolution else { panic!("expected resolved grant"); }; @@ -111,9 +118,10 @@ mod tests { #[tokio::test] async fn unknown_call_path_is_not_available() { let resolver = McpDeferredToolResolver::new(std::iter::empty()); + let mut resolutions = resolver.resolve(&["mystery.run"]).await; assert!(matches!( - resolver.resolve("mystery.run").await, - Resolution::NotAvailable + resolutions.remove("mystery.run"), + Some(Resolution::NotAvailable) )); } } diff --git a/crates/lash-cli/src/interactive/runtime.rs b/crates/lash-cli/src/interactive/runtime.rs index 4032597..2f21041 100644 --- a/crates/lash-cli/src/interactive/runtime.rs +++ b/crates/lash-cli/src/interactive/runtime.rs @@ -85,6 +85,7 @@ pub(crate) async fn make_injected_plugin_message(turn: &PreparedTurn) -> PluginM } PluginMessage { + id: None, role: MessageRole::User, content: turn.effective_text.clone(), origin: None, diff --git a/crates/lash-cli/src/interactive/tests.rs b/crates/lash-cli/src/interactive/tests.rs index b2fd761..eae5af3 100644 --- a/crates/lash-cli/src/interactive/tests.rs +++ b/crates/lash-cli/src/interactive/tests.rs @@ -265,6 +265,7 @@ async fn observation_test_session( request_body: None, http_summary: None, execution_evidence: None, + response_metadata: Default::default(), }) }) .build() diff --git a/crates/lash-cli/src/turn_runner.rs b/crates/lash-cli/src/turn_runner.rs index 847b0e9..08ef8ec 100644 --- a/crates/lash-cli/src/turn_runner.rs +++ b/crates/lash-cli/src/turn_runner.rs @@ -172,6 +172,7 @@ async fn runtime_error_turn_result(session: &LashSession, message: String) -> la execution: ExecutionSummary::default(), state, outcome: TurnOutcome::Stopped(TurnStop::RuntimeError), + cancellation: None, assistant_output: AssistantOutput { safe_text: String::new(), raw_text: String::new(), From a489d856653359cd757aca896d1971345100b3ea Mon Sep 17 00:00:00 2001 From: Samuel Galanakis Date: Sun, 23 Aug 2026 15:52:19 +0200 Subject: [PATCH 02/25] Pin Lash to main 1d33a2c7c (git) and mirror its regress patch lash-typescript and the session-pinned RLM dialect only exist on Lash main; the last published release (alpha.113) predates them. Drop the removed lash-plugin-plan-mode dependency. Release-Notes: Track Lash main 1d33a2c7c instead of alpha.104. --- Cargo.lock | 761 ++++++++++++++++++++++++------------- Cargo.toml | 46 ++- crates/lash-cli/Cargo.toml | 2 +- 3 files changed, 520 insertions(+), 289 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b88f64c..fb50f86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,15 +17,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "adobe-cmap-parser" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae8abfa9a4688de8fc9f42b3f013b6fffec18ed8a554f5f113577e0b9b3212a3" -dependencies = [ - "pom", -] - [[package]] name = "ahash" version = "0.8.12" @@ -49,6 +40,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -156,6 +153,17 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "ast_node" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b127110e8724bd19447ac72f31a66a9c06ac6b2b1372d81b6f5f0a2bddc6d1bb" +dependencies = [ + "quote", + "swc_macros_common", + "syn 2.0.119", +] + [[package]] name = "async-trait" version = "0.1.91" @@ -229,6 +237,15 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "better_scoped_tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd228125315b132eed175bf47619ac79b945b26e56b848ba203ae4ea8603609" +dependencies = [ + "scoped-tls", +] + [[package]] name = "bincode" version = "1.3.3" @@ -270,7 +287,16 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", ] [[package]] @@ -279,6 +305,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -363,6 +395,16 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "bytes-str" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577d2bf5650f8554d5a372af5ac93535110a0fc75b3e702bb853369febf227c2" +dependencies = [ + "bytes", + "serde", +] + [[package]] name = "castaway" version = "0.2.4" @@ -521,6 +563,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] + [[package]] name = "compact_str" version = "0.9.1" @@ -898,15 +953,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -938,15 +984,6 @@ dependencies = [ "cc", ] -[[package]] -name = "euclid" -version = "0.20.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bb7ef65b3777a325d1eeefefab5b6d4959da54747e33bd6258e789640f307ad" -dependencies = [ - "num-traits", -] - [[package]] name = "fallible-iterator" version = "0.3.0" @@ -965,7 +1002,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" dependencies = [ - "bit-set", + "bit-set 0.5.3", "regex-automata", "regex-syntax", ] @@ -1122,6 +1159,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "font8x8" version = "0.3.1" @@ -1147,6 +1190,16 @@ dependencies = [ "num", ] +[[package]] +name = "from_variant" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ff35a391aef949120a0340d690269b3d9f63460a6106e99bd07b961f345ea9" +dependencies = [ + "swc_macros_common", + "syn 2.0.119", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1383,13 +1436,34 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + [[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -1479,6 +1553,20 @@ dependencies = [ "digest", ] +[[package]] +name = "hstr" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23efb2e58d2f27a96edfa907c26016deb120b7ac8c66a19d2748f0e9c145f3cb" +dependencies = [ + "hashbrown 0.14.5", + "new_debug_unreachable", + "once_cell", + "rustc-hash 2.1.3", + "serde", + "triomphe", +] + [[package]] name = "http" version = "1.4.2" @@ -1518,6 +1606,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.10.1" @@ -1789,6 +1883,18 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is-macro" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8267aa6001e25494f3015f9663bbd88a18240c74483afa5f0934a1b3e4c388e9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1908,7 +2014,6 @@ dependencies = [ "anyhow", "base64 0.22.1", "bytecount", - "clap", "fancy-regex", "fraction", "getrandom 0.2.17", @@ -1920,7 +2025,6 @@ dependencies = [ "parking_lot", "percent-encoding", "regex", - "reqwest 0.12.28", "serde", "serde_json", "time", @@ -1990,7 +2094,6 @@ dependencies = [ "lash-lashlang-runtime", "lash-llm-tools", "lash-plugin-mcp", - "lash-plugin-plan-mode", "lash-plugin-process-controls", "lash-protocol-rlm", "lash-protocol-standard", @@ -2011,7 +2114,7 @@ dependencies = [ "model2vec-rs", "pulldown-cmark", "rand 0.9.5", - "reqwest 0.13.4", + "reqwest", "schemars", "semver", "serde", @@ -2039,9 +2142,8 @@ dependencies = [ [[package]] name = "lash-core" -version = "0.1.0-alpha.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7ceb8f5502c3b27f8d574e3fed08e11eaaf716b7d1e9a5bb4a7cd3bfbe02a57" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=1d33a2c7c#1d33a2c7c1b38d7d05542222e05da967eb3f53e5" dependencies = [ "arc-swap", "async-trait", @@ -2051,8 +2153,11 @@ dependencies = [ "lash-http-transport", "lash-sansio", "lash-trace", + "proptest", + "rmp-serde", "schemars", "serde", + "serde_bytes", "serde_json", "sha2", "thiserror 2.0.19", @@ -2107,33 +2212,34 @@ dependencies = [ [[package]] name = "lash-http-transport" -version = "0.1.0-alpha.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5115730f688adfc1a45c09b721eedfcec26391a9d107cf9a0def494340b0140c" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=1d33a2c7c#1d33a2c7c1b38d7d05542222e05da967eb3f53e5" dependencies = [ "async-trait", "bytes", + "httpdate", "lash-sansio", - "reqwest 0.13.4", + "reqwest", "thiserror 2.0.19", "tokio", ] [[package]] name = "lash-lashlang-runtime" -version = "0.1.0-alpha.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba0fc6a5b31149b043bfe31c785673bf809c9bb3c10a4d28811261518230c283" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=1d33a2c7c#1d33a2c7c1b38d7d05542222e05da967eb3f53e5" dependencies = [ "async-trait", "chrono", "lash-core", "lash-remote-protocol", + "lash-sansio", "lash-trace", "lashlang", "serde", "serde_json", "sha2", + "thiserror 2.0.19", "tokio", "tokio-util", "tracing", @@ -2142,23 +2248,22 @@ dependencies = [ [[package]] name = "lash-llm-tools" -version = "0.1.0-alpha.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4813ff034e9534844562f2e1e6f8b0be0789545dd35394c338addcc4120937ce" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=1d33a2c7c#1d33a2c7c1b38d7d05542222e05da967eb3f53e5" dependencies = [ "async-trait", "jsonschema", "lash-core", "lash-lashlang-runtime", + "lash-sansio", "lash-tool-support", "serde_json", ] [[package]] name = "lash-llm-transport" -version = "0.1.0-alpha.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f335ddfb08c0515798541a873a239ee852b23b98412b7a796c3fa92962d9d00" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=1d33a2c7c#1d33a2c7c1b38d7d05542222e05da967eb3f53e5" dependencies = [ "async-trait", "bytes", @@ -2171,58 +2276,32 @@ dependencies = [ [[package]] name = "lash-plugin-mcp" -version = "0.1.0-alpha.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07b2123a8ef4a8c785cdb2ff3a691b48231143c0a7d925eb129adc191baf7e9b" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=1d33a2c7c#1d33a2c7c1b38d7d05542222e05da967eb3f53e5" dependencies = [ "async-trait", "base64 0.22.1", + "fastrand", + "futures-util", "http", + "jsonschema", "lash-core", "lash-lashlang-runtime", + "lash-sansio", "lash-tool-support", "rmcp", "serde", "serde_json", "thiserror 2.0.19", "tokio", + "tokio-util", "tracing", ] -[[package]] -name = "lash-plugin-observational-memory" -version = "0.1.0-alpha.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "781703da17981d68037adcd860e2d64c4fd5020a42e7106b1369eb1cdb886dd5" -dependencies = [ - "async-trait", - "chrono", - "lash-core", - "serde", - "serde_json", -] - -[[package]] -name = "lash-plugin-plan-mode" -version = "0.1.0-alpha.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c929e4b0f7b4b23ed5eb2fbee4f87402ec68a9b010e1a8c3dd19a29c5e472e0d" -dependencies = [ - "async-trait", - "lash-core", - "lash-tool-support", - "schemars", - "serde", - "serde_json", - "tracing", - "uuid", -] - [[package]] name = "lash-plugin-process-controls" -version = "0.1.0-alpha.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ff6f24549ec29a7a83a2dcdc1cd1046fa582eacd2e1d8a907a5cc7ba55dae3f" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=1d33a2c7c#1d33a2c7c1b38d7d05542222e05da967eb3f53e5" dependencies = [ "async-trait", "lash-core", @@ -2233,9 +2312,8 @@ dependencies = [ [[package]] name = "lash-plugin-tool-output-budget" -version = "0.1.0-alpha.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1616df8ef28c48a00d9d54c0947bc93ade4102753ecfcc3ff4f6092ba9f30e46" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=1d33a2c7c#1d33a2c7c1b38d7d05542222e05da967eb3f53e5" dependencies = [ "lash-core", "serde", @@ -2245,46 +2323,50 @@ dependencies = [ [[package]] name = "lash-protocol-rlm" -version = "0.1.0-alpha.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc9224e1473a4a209927f94a6115b9d336c833bf42156eaa1d8ebec07078524f" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=1d33a2c7c#1d33a2c7c1b38d7d05542222e05da967eb3f53e5" dependencies = [ "async-trait", "jsonschema", "lash-core", "lash-lashlang-runtime", "lash-rlm-types", + "lash-sansio", + "lash-typescript", "lashlang", + "rmp-serde", "serde", + "serde_bytes", "serde_json", - "tempfile", + "sha2", + "thiserror 2.0.19", "tokio", "uuid", ] [[package]] name = "lash-protocol-standard" -version = "0.1.0-alpha.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce005b8e71f9149f0ca284bda46a4ae89a66270a9902e92112a2820e62e447fb" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=1d33a2c7c#1d33a2c7c1b38d7d05542222e05da967eb3f53e5" dependencies = [ "async-trait", "lash-core", + "lash-sansio", "lash-tool-support", "serde_json", ] [[package]] name = "lash-provider-anthropic" -version = "0.1.0-alpha.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b8724c8ec9ec4c6742944e210639a521249307b7f7b7cdb1c823dd4f9d48e74" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=1d33a2c7c#1d33a2c7c1b38d7d05542222e05da967eb3f53e5" dependencies = [ "async-trait", "base64 0.22.1", "lash-core", "lash-llm-transport", - "reqwest 0.13.4", + "lash-sansio", + "reqwest", "serde", "serde_json", "uuid", @@ -2292,14 +2374,15 @@ dependencies = [ [[package]] name = "lash-provider-auth" -version = "0.1.0-alpha.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0dad37b4dee717f3fe597079ea8d8dd72c96dd14616cb96e6cca1e86336099e" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=1d33a2c7c#1d33a2c7c1b38d7d05542222e05da967eb3f53e5" dependencies = [ "async-trait", "base64 0.22.1", + "form_urlencoded", "lash-core", - "reqwest 0.13.4", + "lash-sansio", + "reqwest", "serde_json", "sha2", "thiserror 2.0.19", @@ -2309,16 +2392,16 @@ dependencies = [ [[package]] name = "lash-provider-google" -version = "0.1.0-alpha.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c04c965227406c69831bd37b67a8b0287d6a5efa9497451260e491aa95fcd5fa" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=1d33a2c7c#1d33a2c7c1b38d7d05542222e05da967eb3f53e5" dependencies = [ "async-trait", "base64 0.22.1", "lash-core", "lash-llm-transport", "lash-provider-auth", - "reqwest 0.13.4", + "lash-sansio", + "reqwest", "serde", "serde_json", "sha2", @@ -2328,9 +2411,8 @@ dependencies = [ [[package]] name = "lash-provider-openai" -version = "0.1.0-alpha.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07664f5817cdd3f18965a57734c19a9f31a5e63097f116f910cae41e892c3c4c" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=1d33a2c7c#1d33a2c7c1b38d7d05542222e05da967eb3f53e5" dependencies = [ "async-trait", "base64 0.22.1", @@ -2339,7 +2421,8 @@ dependencies = [ "lash-core", "lash-llm-transport", "lash-provider-auth", - "reqwest 0.13.4", + "lash-sansio", + "reqwest", "serde", "serde_json", "tokio", @@ -2350,12 +2433,12 @@ dependencies = [ [[package]] name = "lash-remote-protocol" -version = "0.1.0-alpha.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "361070138fc7b1dd3156f69ae2c9600f66facc520af9932e1430d71bdcd6af87" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=1d33a2c7c#1d33a2c7c1b38d7d05542222e05da967eb3f53e5" dependencies = [ "base64 0.22.1", "lash-core", + "lash-sansio", "schemars", "serde", "serde_json", @@ -2364,9 +2447,8 @@ dependencies = [ [[package]] name = "lash-rlm-types" -version = "0.1.0-alpha.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e87ac6d7bde49f4f14a8b446b2b1cf4f8453af1b838b9f2b6994220c2c8b88" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=1d33a2c7c#1d33a2c7c1b38d7d05542222e05da967eb3f53e5" dependencies = [ "lash-sansio", "serde", @@ -2375,9 +2457,8 @@ dependencies = [ [[package]] name = "lash-runtime" -version = "0.1.0-alpha.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd806acfa9fa6e563c68a204e1d9d04b1a5facea7050e5d833e12814b99746c" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=1d33a2c7c#1d33a2c7c1b38d7d05542222e05da967eb3f53e5" dependencies = [ "async-trait", "futures-util", @@ -2388,6 +2469,7 @@ dependencies = [ "lash-protocol-standard", "lash-remote-protocol", "lash-rlm-types", + "lash-sansio", "lash-tool-support", "lash-trace", "lashlang", @@ -2396,17 +2478,20 @@ dependencies = [ "thiserror 2.0.19", "tokio", "tokio-util", + "tracing", + "uuid", ] [[package]] name = "lash-sansio" -version = "0.1.0-alpha.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d40c47524b24cd92459545cd8725babc0f9007717bcdb8e7b302e0148ecd3fd" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=1d33a2c7c#1d33a2c7c1b38d7d05542222e05da967eb3f53e5" dependencies = [ + "jsonschema", "schemars", "serde", "serde_json", + "sha2", ] [[package]] @@ -2429,51 +2514,53 @@ dependencies = [ [[package]] name = "lash-sqlite-store" -version = "0.1.0-alpha.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5db6026be31fdfa2f77b4babd2d9e85501ff926e9764199f1009ef0d6ec973b4" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=1d33a2c7c#1d33a2c7c1b38d7d05542222e05da967eb3f53e5" dependencies = [ "async-trait", "flate2", "futures-executor", "lash-core", + "lash-sansio", "lashlang", "rmp-serde", "rusqlite", "serde", + "serde_bytes", "serde_json", "sha2", "tokio", "tokio-rusqlite", + "tokio-util", "tracing", + "uuid", ] [[package]] name = "lash-standard-plugins" -version = "0.1.0-alpha.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92724f30e1a7f1636395040c16316dc59b2ba12d97b2b60272cf585f65ae37f4" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=1d33a2c7c#1d33a2c7c1b38d7d05542222e05da967eb3f53e5" dependencies = [ "async-trait", "lash-core", - "lash-plugin-observational-memory", "lash-plugin-process-controls", "lash-plugin-tool-output-budget", + "lash-sansio", "lash-tools", "serde", ] [[package]] name = "lash-subagents" -version = "0.1.0-alpha.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61978b610a55ee2f6e5fe892ad5240d8fd72db12ffac0f2d318fbb9e3a1322e2" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=1d33a2c7c#1d33a2c7c1b38d7d05542222e05da967eb3f53e5" dependencies = [ "async-trait", "lash-core", "lash-lashlang-runtime", "lash-protocol-rlm", "lash-rlm-types", + "lash-sansio", "lash-tool-support", "serde", "serde_json", @@ -2482,51 +2569,42 @@ dependencies = [ [[package]] name = "lash-tool-support" -version = "0.1.0-alpha.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a4b6461a5bd6f05c0099bff427bd7dc049319168897500acd397f8beff7c5b9" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=1d33a2c7c#1d33a2c7c1b38d7d05542222e05da967eb3f53e5" dependencies = [ "async-trait", - "ignore", "lash-core", "lash-lashlang-runtime", - "schemars", - "serde", "serde_json", - "similar", - "tokio", ] [[package]] name = "lash-tools" -version = "0.1.0-alpha.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38759ff575c98ea7dd8d013faa647200d1acff85e6e65fcfffa606cbbcfcf02e" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=1d33a2c7c#1d33a2c7c1b38d7d05542222e05da967eb3f53e5" dependencies = [ "async-trait", - "globset", "lash-core", "lash-lashlang-runtime", + "lash-sansio", "lash-tool-support", "libc", - "pdf-extract", "portable-pty", - "reqwest 0.13.4", + "reqwest", "schemars", "serde", "serde_json", "tokio", "tokio-util", - "unicode-normalization", ] [[package]] name = "lash-trace" -version = "0.1.0-alpha.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0345917d20dc547d06fdf0b37879dce899e9716876e799a185dc80d6674776a" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=1d33a2c7c#1d33a2c7c1b38d7d05542222e05da967eb3f53e5" dependencies = [ "chrono", + "lash-sansio", "serde", "serde_json", "sha2", @@ -2553,21 +2631,41 @@ dependencies = [ "serde_json", ] +[[package]] +name = "lash-typescript" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=1d33a2c7c#1d33a2c7c1b38d7d05542222e05da967eb3f53e5" +dependencies = [ + "lashlang", + "ryu-js", + "serde_json", + "swc_common", + "swc_ecma_ast", + "swc_ecma_parser", +] + [[package]] name = "lashlang" -version = "0.1.0-alpha.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2672f55899fe02403e40d01d04507eccc73b55042be91bde41750767801623f7" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=1d33a2c7c#1d33a2c7c1b38d7d05542222e05da967eb3f53e5" dependencies = [ "async-trait", - "compact_str", + "compact_str 0.9.1", "futures-executor", + "lash-core", + "lash-sansio", + "num-bigint", + "num-traits", + "regress", + "rmp-serde", "rustc-hash 2.1.3", + "ryu-js", "serde", "serde_json", "sha2", "smallvec", "thiserror 2.0.19", + "url", ] [[package]] @@ -2680,24 +2778,6 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" -[[package]] -name = "lopdf" -version = "0.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5c8ecfc6c72051981c0459f75ccc585e7ff67c70829560cda8e647882a9abff" -dependencies = [ - "encoding_rs", - "flate2", - "indexmap", - "itoa", - "log", - "md-5", - "nom 7.1.3", - "rangemap", - "time", - "weezl", -] - [[package]] name = "lru-slab" version = "0.1.2" @@ -2739,16 +2819,6 @@ dependencies = [ "rawpointer", ] -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest", -] - [[package]] name = "memchr" version = "2.8.3" @@ -2869,6 +2939,12 @@ dependencies = [ "raw-cpuid", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nix" version = "0.28.0" @@ -2993,6 +3069,7 @@ checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", + "serde", ] [[package]] @@ -3254,21 +3331,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" -[[package]] -name = "pdf-extract" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbb3a5387b94b9053c1e69d8abfd4dd6dae7afda65a5c5279bc1f42ab39df575" -dependencies = [ - "adobe-cmap-parser", - "encoding_rs", - "euclid", - "lopdf", - "postscript", - "type1-encoding-parser", - "unicode-normalization", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -3325,7 +3387,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ - "siphasher", + "siphasher 1.0.3", ] [[package]] @@ -3353,12 +3415,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "pom" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6" - [[package]] name = "portable-atomic" version = "1.14.0" @@ -3386,12 +3442,6 @@ dependencies = [ "winreg", ] -[[package]] -name = "postscript" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78451badbdaebaf17f053fd9152b3ffb33b516104eacb45e7864aaa9c712f306" - [[package]] name = "potential_utf" version = "0.1.5" @@ -3449,6 +3499,25 @@ dependencies = [ "windows", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.13.1", + "num-traits", + "rand 0.9.5", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "pulldown-cmark" version = "0.13.4" @@ -3474,6 +3543,12 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-error" version = "2.0.1" @@ -3638,10 +3713,13 @@ dependencies = [ ] [[package]] -name = "rangemap" -version = "1.7.1" +name = "rand_xorshift" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] [[package]] name = "raw-cpuid" @@ -3750,37 +3828,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +name = "regress" +version = "0.11.1" +source = "git+https://github.com/Ascending-AI/lash.git?rev=1d33a2c7c#1d33a2c7c1b38d7d05542222e05da967eb3f53e5" dependencies = [ - "base64 0.22.1", - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", + "hashbrown 0.16.1", + "memchr", ] [[package]] @@ -3851,7 +3904,7 @@ dependencies = [ "http", "pin-project-lite", "process-wrap", - "reqwest 0.13.4", + "reqwest", "serde", "serde_json", "sse-stream", @@ -3860,6 +3913,7 @@ dependencies = [ "tokio-stream", "tokio-util", "tracing", + "url", ] [[package]] @@ -4031,12 +4085,30 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "safetensors" version = "0.5.3" @@ -4089,6 +4161,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -4124,6 +4202,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + [[package]] name = "serde" version = "1.0.229" @@ -4134,6 +4218,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -4323,10 +4417,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] -name = "similar" -version = "2.7.0" +name = "siphasher" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" [[package]] name = "siphasher" @@ -4416,6 +4510,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "string_enum" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36a4951ca7bd1cfd991c241584a9824a70f6aff1e7d4f693fb3f2465e4030e" +dependencies = [ + "quote", + "swc_macros_common", + "syn 2.0.119", +] + [[package]] name = "strsim" version = "0.11.1" @@ -4428,6 +4533,113 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "swc_atoms" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c70a493080ceb12dabddb96905a3ddac679fbe9c71ddc81a2a769126e8cde7c4" +dependencies = [ + "hstr", + "once_cell", + "serde", +] + +[[package]] +name = "swc_common" +version = "25.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872e3beee92f0d63a7948ac03266c72a91cabf4c54d660ce992d448135ab5aec" +dependencies = [ + "anyhow", + "ast_node", + "better_scoped_tls", + "bytes-str", + "either", + "from_variant", + "num-bigint", + "once_cell", + "rustc-hash 2.1.3", + "serde", + "siphasher 0.3.11", + "swc_atoms", + "swc_eq_ignore_macros", + "swc_visit", + "tracing", + "unicode-width", + "url", +] + +[[package]] +name = "swc_ecma_ast" +version = "28.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08b25e2e91491e0e82f932c7a5a07f5c90bd227dbde0c34aeb231d5b3a5e5107" +dependencies = [ + "bitflags 2.13.1", + "is-macro", + "num-bigint", + "once_cell", + "phf", + "rustc-hash 2.1.3", + "string_enum", + "swc_atoms", + "swc_common", + "swc_visit", + "unicode-id-start", +] + +[[package]] +name = "swc_ecma_parser" +version = "44.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c83ff3be8652d2caf9f074853f8c33a4a05b17079c1586f195eec17d3ec553" +dependencies = [ + "bitflags 2.13.1", + "compact_str 0.7.1", + "either", + "num-bigint", + "phf", + "rustc-hash 2.1.3", + "seq-macro", + "serde", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "tracing", +] + +[[package]] +name = "swc_eq_ignore_macros" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c16ce73424a6316e95e09065ba6a207eba7765496fed113702278b7711d4b632" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "swc_macros_common" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1efbaa74943dc5ad2a2fb16cbd78b77d7e4d63188f3c5b4df2b4dcd2faaae" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "swc_visit" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62fb71484b486c185e34d2172f0eabe7f4722742aad700f426a494bb2de232a2" +dependencies = [ + "either", + "new_debug_unreachable", +] + [[package]] name = "symlink" version = "0.1.0" @@ -4562,7 +4774,7 @@ dependencies = [ "fax", "flate2", "half", - "quick-error", + "quick-error 2.0.1", "weezl", "zune-jpeg", ] @@ -4630,7 +4842,7 @@ checksum = "a620b996116a59e184c2fa2dfd8251ea34a36d0a514758c6f966386bd2e03476" dependencies = [ "ahash", "aho-corasick", - "compact_str", + "compact_str 0.9.1", "dary_heap", "derive_builder", "esaxx-rs", @@ -4931,6 +5143,16 @@ dependencies = [ "petgraph", ] +[[package]] +name = "triomphe" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" +dependencies = [ + "serde", + "stable_deref_trait", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -4955,21 +5177,18 @@ dependencies = [ "thiserror 2.0.19", ] -[[package]] -name = "type1-encoding-parser" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa10c302f5a53b7ad27fd42a3996e23d096ba39b5b8dd6d9e683a05b01bee749" -dependencies = [ - "pom", -] - [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicase" version = "2.9.0" @@ -4977,19 +5196,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "unicode-id-start" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007" [[package]] -name = "unicode-normalization" -version = "0.1.25" +name = "unicode-ident" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-normalization-alignments" @@ -5117,6 +5333,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 8fd7b19..b868435 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,26 +49,28 @@ uuid = "1" # The CLI tracks one reviewed Lash release. Dependency updates are explicit, # testable compatibility changes rather than an implicit lockstep workspace. -lash = { package = "lash-runtime", version = "0.1.0-alpha.104" } -lash-core = { version = "0.1.0-alpha.104" } -lash-lashlang-runtime = { version = "0.1.0-alpha.104" } -lash-llm-tools = { version = "0.1.0-alpha.104" } -lash-plugin-mcp = { version = "0.1.0-alpha.104" } -lash-plugin-plan-mode = { version = "0.1.0-alpha.104" } -lash-plugin-process-controls = { version = "0.1.0-alpha.104" } -lash-protocol-rlm = { version = "0.1.0-alpha.104" } -lash-protocol-standard = { version = "0.1.0-alpha.104" } -lash-provider-anthropic = { version = "0.1.0-alpha.104" } -lash-provider-auth = { version = "0.1.0-alpha.104" } -lash-provider-google = { version = "0.1.0-alpha.104" } -lash-provider-openai = { version = "0.1.0-alpha.104" } -lash-rlm-types = { version = "0.1.0-alpha.104" } -lash-sqlite-store = { version = "0.1.0-alpha.104" } -lash-standard-plugins = { version = "0.1.0-alpha.104" } -lash-subagents = { version = "0.1.0-alpha.104" } -lash-tool-support = { version = "0.1.0-alpha.104" } -lash-trace = { version = "0.1.0-alpha.104" } -lashlang = { version = "0.1.0-alpha.104" } +lash = { package = "lash-runtime", git = "https://github.com/Ascending-AI/lash.git", rev = "1d33a2c7c" } +lash-core = { git = "https://github.com/Ascending-AI/lash.git", rev = "1d33a2c7c" } +lash-lashlang-runtime = { git = "https://github.com/Ascending-AI/lash.git", rev = "1d33a2c7c" } +lash-llm-tools = { git = "https://github.com/Ascending-AI/lash.git", rev = "1d33a2c7c" } +lash-plugin-mcp = { git = "https://github.com/Ascending-AI/lash.git", rev = "1d33a2c7c" } +# lash-plugin-plan-mode removed upstream +# lash-plugin-plan-mode = { git = "https://github.com/Ascending-AI/lash.git", rev = "1d33a2c7c" } +lash-plugin-process-controls = { git = "https://github.com/Ascending-AI/lash.git", rev = "1d33a2c7c" } +lash-protocol-rlm = { git = "https://github.com/Ascending-AI/lash.git", rev = "1d33a2c7c" } +lash-protocol-standard = { git = "https://github.com/Ascending-AI/lash.git", rev = "1d33a2c7c" } +lash-provider-anthropic = { git = "https://github.com/Ascending-AI/lash.git", rev = "1d33a2c7c" } +lash-provider-auth = { git = "https://github.com/Ascending-AI/lash.git", rev = "1d33a2c7c" } +lash-provider-google = { git = "https://github.com/Ascending-AI/lash.git", rev = "1d33a2c7c" } +lash-provider-openai = { git = "https://github.com/Ascending-AI/lash.git", rev = "1d33a2c7c" } +lash-rlm-types = { git = "https://github.com/Ascending-AI/lash.git", rev = "1d33a2c7c" } +lash-sqlite-store = { git = "https://github.com/Ascending-AI/lash.git", rev = "1d33a2c7c" } +lash-standard-plugins = { git = "https://github.com/Ascending-AI/lash.git", rev = "1d33a2c7c" } +lash-subagents = { git = "https://github.com/Ascending-AI/lash.git", rev = "1d33a2c7c" } +lash-tool-support = { git = "https://github.com/Ascending-AI/lash.git", rev = "1d33a2c7c" } +lash-trace = { git = "https://github.com/Ascending-AI/lash.git", rev = "1d33a2c7c" } +lash-typescript = { git = "https://github.com/Ascending-AI/lash.git", rev = "1d33a2c7c" } +lashlang = { git = "https://github.com/Ascending-AI/lash.git", rev = "1d33a2c7c" } lash-autoresearch = { path = "crates/lash-autoresearch" } lash-cli-perf = { path = "crates/lash-cli-perf" } @@ -81,3 +83,7 @@ lash-tui-extensions = { path = "crates/lash-tui-extensions" } [profile.release] lto = "thin" strip = true + +# Lash main pins a fuel-metered fork of regress via [patch]; downstream hosts must mirror it. +[patch.crates-io] +regress = { git = "https://github.com/Ascending-AI/lash.git", rev = "1d33a2c7c" } diff --git a/crates/lash-cli/Cargo.toml b/crates/lash-cli/Cargo.toml index e8dad03..4e0281c 100644 --- a/crates/lash-cli/Cargo.toml +++ b/crates/lash-cli/Cargo.toml @@ -38,7 +38,7 @@ lash-standard-plugins = { workspace = true, features = ["lashlang"] } lash-protocol-rlm = { workspace = true } lash-protocol-standard = { workspace = true } lash-rlm-types = { workspace = true } -lash-plugin-plan-mode = { workspace = true, features = ["lashlang"] } +# lash-plugin-plan-mode = removed upstream lash-provider-anthropic = { workspace = true } lash-provider-auth = { workspace = true } lash-provider-google = { workspace = true } From 9b4351d8fc59b57c606d3358f0208bd6154aa776 Mon Sep 17 00:00:00 2001 From: Samuel Galanakis Date: Sun, 23 Aug 2026 16:00:57 +0200 Subject: [PATCH 03/25] Port search tools to Lash main Use the current tool outcome and cross-dialect binding APIs, remove per-tool scheduling, and resolve paths without the deleted file-tool helper. Release-Notes: Port local search tools to the current Lash tool contract. --- crates/lash-search-tools/src/lib.rs | 98 ++++++++++++++++------------- 1 file changed, 56 insertions(+), 42 deletions(-) diff --git a/crates/lash-search-tools/src/lib.rs b/crates/lash-search-tools/src/lib.rs index 3fbb6ed..f46f0ff 100644 --- a/crates/lash-search-tools/src/lib.rs +++ b/crates/lash-search-tools/src/lib.rs @@ -16,13 +16,11 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use lash_core::{ - ToolCall, ToolDefinition, ToolFailureClass, ToolResult, ToolRetryPolicy, ToolScheduling, -}; +use lash_core::{ToolCall, ToolDefinition, ToolFailureClass, ToolOutcome, ToolRetryPolicy}; use lash_tool_support::{ - StaticToolExecute, StaticToolProvider, ToolDefinitionLashlangExt, canonicalize_under, - invalid_tool_args, non_empty_string, typed_tool_args, typed_tool_ok, + StaticToolExecute, StaticToolProvider, ToolDefinitionBindingExt, execution_failure, + invalid_tool_args, tool_binding, }; const DEFAULT_MAX_RESULTS: usize = 20; @@ -148,12 +146,12 @@ impl Grep { } } - fn ensure_ready_for_query(&self, query: &str) -> Result, ToolResult> { + fn ensure_ready_for_query(&self, query: &str) -> Result, ToolOutcome> { let backend = self .backend .get_or_init(|| self.shared_backend()) .as_ref() - .map_err(|err| ToolResult::err_fmt(format_args!("{err}")))?; + .map_err(|err| ToolOutcome::err_fmt(format_args!("{err}")))?; if !backend.picker.wait_for_scan(GREP_WALL_TIMEOUT) { return Err(timeout_grep_result( query, @@ -172,10 +170,10 @@ impl Grep { fn lock_cursors( cursor_store: &Mutex, - ) -> Result, ToolResult> { + ) -> Result, ToolOutcome> { cursor_store .lock() - .map_err(|_| ToolResult::err_fmt(format_args!("Failed to acquire cursor store lock"))) + .map_err(|_| ToolOutcome::err_fmt(format_args!("Failed to acquire cursor store lock"))) } fn perform_grep( @@ -186,7 +184,7 @@ impl Grep { max_results: usize, cursor_id: Option<&str>, control: &GrepRunControl, - ) -> Result { + ) -> Result { control.check(query)?; let file_offset = cursor_id .and_then(|id| cursor_store.lock().ok()?.get(id)) @@ -195,11 +193,11 @@ impl Grep { let (options, auto_expand) = make_grep_options(mode, file_offset, control); let guard = backend.picker.read().map_err(|err| { - ToolResult::err_fmt(format_args!("Failed to acquire picker lock: {err}")) + ToolOutcome::err_fmt(format_args!("Failed to acquire picker lock: {err}")) })?; let picker = guard .as_ref() - .ok_or_else(|| ToolResult::err_fmt(format_args!("File picker not initialized")))?; + .ok_or_else(|| ToolOutcome::err_fmt(format_args!("File picker not initialized")))?; let parser = QueryParser::new(AiGrepConfig); let parsed = parser.parse(query); @@ -367,11 +365,13 @@ pub fn grep_provider() -> StaticToolProvider { #[async_trait::async_trait] impl StaticToolExecute for Grep { - async fn execute(&self, call: ToolCall<'_>) -> ToolResult { + async fn execute(&self, call: ToolCall<'_>) -> ToolOutcome { let cancellation_token = call.context.cancellation_token().cloned(); - let args = match typed_tool_args::(call.args) { + let args = match serde_json::from_value::(call.args.clone()) { Ok(args) => args, - Err(err) => return err, + Err(err) => { + return invalid_tool_args(format!("Invalid tool arguments: {err}")); + } }; self.execute_inner(args, cancellation_token).await } @@ -388,12 +388,11 @@ fn grep_tool_definition() -> ToolDefinition { r#"await files.grep({ query: "*.rs files.edit", path: "." })?"#.into(), r#"await files.grep({ query: "current_query" })?"#.into(), ]) - .with_lashlang_binding(lash_tool_support::lashlang_binding( + .with_tool_binding(tool_binding( ["files"], "grep", &["search_files", "ripgrep"], )) - .with_scheduling(ToolScheduling::Parallel) .with_retry_policy(ToolRetryPolicy::safe(2, 50, 150)) } @@ -402,9 +401,9 @@ impl Grep { &self, args: GrepArgs, cancellation_token: Option, - ) -> ToolResult { - if let Err(err) = non_empty_string(&args.query, "query") { - return err; + ) -> ToolOutcome { + if args.query.is_empty() { + return invalid_tool_args("Missing required parameter: query"); } if args.limit < 1 { return invalid_tool_args("Invalid limit: must be >= 1"); @@ -448,7 +447,7 @@ impl Grep { Ok(PathScope::Directory(base_path)) => { let backend = match backend_for_base(&base_path) { Ok(backend) => backend, - Err(err) => return ToolResult::err_fmt(format_args!("{err}")), + Err(err) => return ToolOutcome::err_fmt(format_args!("{err}")), }; if !backend.picker.wait_for_scan(GREP_WALL_TIMEOUT) { return timeout_grep_result( @@ -512,7 +511,7 @@ impl GrepRunControl { } } - fn check(&self, query: &str) -> Result<(), ToolResult> { + fn check(&self, query: &str) -> Result<(), ToolOutcome> { if self.abort_signal.load(Ordering::Relaxed) { return Err(cancelled_grep_result(query)); } @@ -544,7 +543,7 @@ async fn bounded_indexed_grep( max_results: usize, cursor: Option, cancellation_token: Option, -) -> ToolResult { +) -> ToolOutcome { let abort_signal = Arc::new(AtomicBool::new(false)); let cancellation_watcher = cancellation_token.map(|token| { let abort_signal = Arc::clone(&abort_signal); @@ -568,9 +567,9 @@ async fn bounded_indexed_grep( }); let result = match tokio::time::timeout(GREP_WALL_TIMEOUT, handle).await { - Ok(Ok(Ok(value))) => typed_tool_ok(value), + Ok(Ok(Ok(value))) => serialize_grep_output(value), Ok(Ok(Err(err))) => err, - Ok(Err(err)) => ToolResult::err(grep_output_value(grep_error_output( + Ok(Err(err)) => ToolOutcome::err(grep_output_value(grep_error_output( &timeout_query, &timeout_query, "panic", @@ -601,7 +600,7 @@ async fn direct_file_grep( default_base: Option<&Path>, max_results: usize, cancellation_token: Option, -) -> ToolResult { +) -> ToolOutcome { let query = query.to_string(); let file_path = file_path.to_path_buf(); let default_base = default_base.map(Path::to_path_buf); @@ -626,7 +625,7 @@ async fn direct_file_grep( }); let result = match tokio::time::timeout(GREP_WALL_TIMEOUT, handle).await { Ok(Ok(result)) => result, - Ok(Err(err)) => ToolResult::err(grep_output_value(grep_error_output( + Ok(Err(err)) => ToolOutcome::err(grep_output_value(grep_error_output( &timeout_query, &timeout_query, "panic", @@ -657,7 +656,7 @@ async fn direct_file_grep( fn resolve_path_scope( default_base: Option<&Path>, requested: &str, -) -> Result { +) -> Result { let candidate = Path::new(requested); // Resolve relative paths against the search base (falling back to the // process cwd) and then canonicalize on disk: search needs a real, @@ -666,11 +665,16 @@ fn resolve_path_scope( let base = match default_base { Some(base) => base.to_path_buf(), None => std::env::current_dir().map_err(|err| { - ToolResult::err_fmt(format_args!("failed to resolve current directory: {err}")) + ToolOutcome::err_fmt(format_args!("failed to resolve current directory: {err}")) })?, }; - let canonical = canonicalize_under(&base, candidate).map_err(|err| { - ToolResult::err_fmt(format_args!( + let resolved = if candidate.is_absolute() { + candidate.to_path_buf() + } else { + base.join(candidate) + }; + let canonical = std::fs::canonicalize(resolved).map_err(|err| { + ToolOutcome::err_fmt(format_args!( "`path` {requested} does not exist or is not accessible: {err}" )) })?; @@ -788,14 +792,14 @@ fn direct_file_grep_sync( default_base: Option<&Path>, max_results: usize, abort_signal: &AtomicBool, -) -> ToolResult { +) -> ToolOutcome { if abort_signal.load(Ordering::Relaxed) { return cancelled_grep_result(query); } let metadata = match std::fs::metadata(file_path) { Ok(metadata) => metadata, Err(err) => { - return ToolResult::err(grep_output_value(grep_error_output( + return ToolOutcome::err(grep_output_value(grep_error_output( query, query, "io", @@ -807,7 +811,7 @@ fn direct_file_grep_sync( } }; if !metadata.is_file() { - return ToolResult::err(grep_output_value(grep_error_output( + return ToolOutcome::err(grep_output_value(grep_error_output( query, query, "not_a_file", @@ -821,7 +825,7 @@ fn direct_file_grep_sync( let mut extra = BTreeMap::new(); extra.insert("size_bytes".to_string(), json!(metadata.len())); extra.insert("max_size_bytes".to_string(), json!(DIRECT_FILE_MAX_SIZE)); - return ToolResult::err(grep_output_value(grep_error_output( + return ToolOutcome::err(grep_output_value(grep_error_output( query, query, "file_too_large", @@ -835,13 +839,13 @@ fn direct_file_grep_sync( let parsed = QueryParser::new(AiGrepConfig).parse(query); let grep_text = parsed.grep_text(); if grep_text.is_empty() { - return typed_tool_ok(empty_grep_result(query)); + return serialize_grep_output(empty_grep_result(query)); } let bytes = match std::fs::read(file_path) { Ok(bytes) => bytes, Err(err) => { - return ToolResult::err(grep_output_value(grep_error_output( + return ToolOutcome::err(grep_output_value(grep_error_output( query, &grep_text, "io", @@ -911,7 +915,7 @@ fn direct_file_grep_sync( Vec::new() }; - typed_tool_ok(GrepOutput { + serialize_grep_output(GrepOutput { query: query.to_string(), query_used: grep_text, broadened_from: None, @@ -935,6 +939,16 @@ fn direct_file_grep_sync( }) } +fn serialize_grep_output(output: GrepOutput) -> ToolOutcome { + match serde_json::to_value(output) { + Ok(value) => ToolOutcome::ok(value), + Err(err) => execution_failure( + "tool_result_serialization_failed", + format!("Failed to serialize grep result: {err}"), + ), + } +} + enum DirectMatcher { Literal { needle: String, @@ -1168,7 +1182,7 @@ fn grep_error_output( } } -fn timeout_grep_result(query: &str, stage: &str, budget: Duration, message: &str) -> ToolResult { +fn timeout_grep_result(query: &str, stage: &str, budget: Duration, message: &str) -> ToolOutcome { let mut extra = BTreeMap::new(); extra.insert("budget_ms".to_string(), json!(budget.as_millis() as u64)); let raw = grep_output_value(grep_error_output( @@ -1190,11 +1204,11 @@ fn timeout_grep_result(query: &str, stage: &str, budget: Duration, message: &str Some(50), ); failure.raw = Some(lash_core::ToolValue::from(raw)); - ToolResult::failure(failure) + ToolOutcome::failure(failure) } -fn cancelled_grep_result(query: &str) -> ToolResult { - ToolResult::cancelled_with_raw( +fn cancelled_grep_result(query: &str) -> ToolOutcome { + ToolOutcome::cancelled_with_raw( "grep cancelled", grep_output_value(grep_error_output( query, From f87469e94beeae05f39f694a3c2b34389a2123a5 Mon Sep 17 00:00:00 2001 From: Samuel Galanakis Date: Sun, 23 Aug 2026 16:04:57 +0200 Subject: [PATCH 04/25] Port TUI extensions to Lash main Match the current shared prose-delta representation so extension event handling remains type-correct against the Lash main turn surface. Release-Notes: Keep TUI extensions compatible with current Lash turn events. --- crates/lash-tui-extensions/src/lib.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/lash-tui-extensions/src/lib.rs b/crates/lash-tui-extensions/src/lib.rs index 1b84a0f..9b13b57 100644 --- a/crates/lash-tui-extensions/src/lib.rs +++ b/crates/lash-tui-extensions/src/lib.rs @@ -981,7 +981,7 @@ mod tests { fn handle_turn_event(&self, event: &TurnEvent) -> Vec { match event { - TurnEvent::AssistantProseDelta { text } if text == "mount" => vec![ + TurnEvent::AssistantProseDelta { text } if text.as_ref() == "mount" => vec![ TuiHostEffect::MountSurface { spec: TuiSurfaceSpec { key: "workspace".to_string(), @@ -1008,7 +1008,7 @@ mod tests { key: "workspace".to_string(), }, ], - TurnEvent::AssistantProseDelta { text } if text == "overlay" => vec![ + TurnEvent::AssistantProseDelta { text } if text.as_ref() == "overlay" => vec![ TuiHostEffect::MountSurface { spec: TuiSurfaceSpec { key: "overlay".to_string(), @@ -1027,7 +1027,7 @@ mod tests { key: "overlay".to_string(), }, ], - TurnEvent::AssistantProseDelta { text } if text == "close" => vec![ + TurnEvent::AssistantProseDelta { text } if text.as_ref() == "close" => vec![ TuiHostEffect::BlurSurface { key: "overlay".to_string(), }, @@ -1046,7 +1046,7 @@ mod tests { let extensions = TuiExtensions::new(vec![harness]).expect("surface harness"); extensions.effects_for_turn_event(&TurnEvent::AssistantProseDelta { - text: "mount".to_string(), + text: "mount".into(), }); assert_eq!( extensions.focused_surface().as_deref(), @@ -1059,7 +1059,7 @@ mod tests { assert_eq!(extensions.mounted_surfaces(TuiSurfaceSlot::Footer).len(), 1); extensions.effects_for_turn_event(&TurnEvent::AssistantProseDelta { - text: "overlay".to_string(), + text: "overlay".into(), }); assert_eq!( extensions.focused_surface().as_deref(), @@ -1067,7 +1067,7 @@ mod tests { ); extensions.effects_for_turn_event(&TurnEvent::AssistantProseDelta { - text: "close".to_string(), + text: "close".into(), }); assert_eq!( extensions.focused_surface().as_deref(), @@ -1081,7 +1081,7 @@ mod tests { let extensions = TuiExtensions::new(vec![Arc::clone(&harness) as Arc]) .expect("surface harness"); extensions.effects_for_turn_event(&TurnEvent::AssistantProseDelta { - text: "mount".to_string(), + text: "mount".into(), }); let workspace = extensions .mounted_surfaces(TuiSurfaceSlot::Workspace) From 65e5608d643d1df78d15e70a8003ad8cfe53406d Mon Sep 17 00:00:00 2001 From: Samuel Galanakis Date: Sun, 23 Aug 2026 16:11:46 +0200 Subject: [PATCH 05/25] Port autoresearch to Lash main Adopt unified plugin and tool outcomes, current cross-dialect bindings, facade turn types, and the progress-free attempt execution contract. Release-Notes: Port autoresearch to the current Lash plugin and tool APIs. --- crates/lash-autoresearch/src/runtime.rs | 54 ++++++------- .../lash-autoresearch/src/runtime/commands.rs | 54 ++++++------- .../lash-autoresearch/src/runtime/summary.rs | 8 +- crates/lash-autoresearch/src/runtime/tools.rs | 75 ++++++++----------- 4 files changed, 91 insertions(+), 100 deletions(-) diff --git a/crates/lash-autoresearch/src/runtime.rs b/crates/lash-autoresearch/src/runtime.rs index 2d7bbaf..dca1d2e 100644 --- a/crates/lash-autoresearch/src/runtime.rs +++ b/crates/lash-autoresearch/src/runtime.rs @@ -5,19 +5,20 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use async_trait::async_trait; +use lash_core::facade_support::{ToolCatalogContribution, TurnOutcome}; use lash_core::plugin::{ - PluginCommand, PluginCommandContext, PluginCommandOutcome, PluginDirective, PluginError, - PluginFactory, PluginOperation, PluginOperationFailure, PluginRegistrar, + PluginCommand, PluginCommandContext, PluginDirective, PluginError, PluginFactory, + PluginOperation, PluginOperationFailure, PluginOperationOutcome, PluginRegistrar, PluginRuntimeDirective, PluginSessionContext, PluginSnapshotMeta, PluginTask, - PluginTaskContext, PluginTaskOutcome, PromptHookContext, SessionParam, SessionPlugin, - SnapshotReader, SnapshotWriter, ToolCallHookContext, ToolCatalogContext, ToolResultHookContext, + PluginTaskContext, PromptHookContext, SessionParam, SessionPlugin, SnapshotReader, + SnapshotWriter, ToolCallHookContext, ToolCatalogContext, ToolResultHookContext, TurnResultHookContext, }; use lash_core::{ - MessageRole, PluginMessage, PluginRuntimeEvent, PromptContribution, ToolCall, - ToolCatalogContribution, ToolContext, ToolContract, ToolDefinition, ToolManifest, ToolProvider, - ToolResult, ToolScheduling, + MessageRole, PluginMessage, PluginRuntimeEvent, PromptContribution, ToolCall, ToolContract, + ToolDefinition, ToolManifest, ToolOutcome, ToolProvider, }; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tokio::io::AsyncReadExt; @@ -75,20 +76,20 @@ struct SummaryState { last_run: Option, } -#[derive(Clone, Debug, Default, Serialize, Deserialize, lash_core::JsonSchema)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] pub struct AutoresearchEmptyArgs {} -#[derive(Clone, Debug, Default, Serialize, Deserialize, lash_core::JsonSchema)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] pub struct AutoresearchStartArgs { pub objective: Option, } -#[derive(Clone, Debug, Default, Serialize, Deserialize, lash_core::JsonSchema)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] pub struct AutoresearchCommandArgs { pub raw: Option, } -#[derive(Clone, Debug, Serialize, Deserialize, lash_core::JsonSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct AutoresearchCommandOutput { pub status: StatusSummary, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -96,7 +97,7 @@ pub struct AutoresearchCommandOutput { pub message: String, } -#[derive(Clone, Debug, Serialize, Deserialize, lash_core::JsonSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct AutoresearchExportOutput { pub status: StatusSummary, pub path: String, @@ -151,11 +152,11 @@ async fn run_autoresearch_command( root: &Path, state: &Arc>, args: AutoresearchCommandArgs, -) -> Result, PluginOperationFailure> { +) -> Result, PluginOperationFailure> { let raw = args.raw.as_deref().map(str::trim).unwrap_or_default(); if raw.eq_ignore_ascii_case("help") { - let status: StatusSummary = tool_result_output(status_tool_result(root, state))?; - return Ok(PluginCommandOutcome::new(AutoresearchCommandOutput { + let status: StatusSummary = tool_outcome_output(status_tool_outcome(root, state))?; + return Ok(PluginOperationOutcome::new(AutoresearchCommandOutput { status, queued_input: None, message: autoresearch_help_text(), @@ -176,14 +177,15 @@ async fn run_autoresearch_command( ) .await }; - let output: AutoresearchCommandOutput = tool_result_output(result)?; + let output: AutoresearchCommandOutput = tool_outcome_output(result)?; let mut directives = Vec::new(); if let Some(input) = output.queued_input.clone() { - directives.push(PluginRuntimeDirective::queue_turn( - lash_core::TurnInput::text(input), - )); + directives.push(PluginRuntimeDirective::QueueTurn { + input: lash_core::TurnInput::text(input), + source_key: None, + }); } - Ok(PluginCommandOutcome::new(output.clone()) + Ok(PluginOperationOutcome::new(output.clone()) .with_events(vec![status_event(&output.status)?]) .with_directives(directives)) } @@ -318,7 +320,7 @@ impl SessionPlugin for AutoresearchPlugin { if !state.mode.active || !matches!( &ctx.turn.outcome, - lash_core::TurnOutcome::Finished(_) | lash_core::TurnOutcome::AgentFrameSwitch { .. } + TurnOutcome::Finished(_) | TurnOutcome::AgentFrameSwitch { .. } ) { return Ok(Vec::new()); @@ -370,8 +372,8 @@ impl SessionPlugin for AutoresearchPlugin { let state = Arc::clone(&state); async move { let output: AutoresearchExportOutput = - tool_result_output(export_summary(&root, &state))?; - Ok(PluginTaskOutcome::new(output.clone()) + tool_outcome_output(export_summary(&root, &state))?; + Ok(PluginOperationOutcome::new(output.clone()) .with_events(vec![status_event(&output.status)?])) } } @@ -480,7 +482,7 @@ mod tests { PluginRuntimeDirective::QueueTurn { input, .. } => { input.items.iter().find_map(|item| match item { InputItem::Text { text } => Some(text.as_str()), - InputItem::ImageRef { .. } => None, + InputItem::Attachment { .. } => None, }) } } @@ -686,7 +688,7 @@ mod tests { fn autoresearch_export_writes_artifact_without_ui_status_rpc() { let dir = tempdir().expect("tempdir"); let state = Arc::new(Mutex::new(RuntimeState::default())); - let start: AutoresearchCommandOutput = tool_result_output(start_mode( + let start: AutoresearchCommandOutput = tool_outcome_output(start_mode( dir.path(), &state, serde_json::json!({ "objective": "improve latency" }), @@ -695,7 +697,7 @@ mod tests { assert!(start.status.active); let output: AutoresearchExportOutput = - tool_result_output(export_summary(dir.path(), &state)).expect("export"); + tool_outcome_output(export_summary(dir.path(), &state)).expect("export"); assert!(output.status.active); assert!(Path::new(&output.path).exists()); diff --git a/crates/lash-autoresearch/src/runtime/commands.rs b/crates/lash-autoresearch/src/runtime/commands.rs index 64ae3aa..da8eba9 100644 --- a/crates/lash-autoresearch/src/runtime/commands.rs +++ b/crates/lash-autoresearch/src/runtime/commands.rs @@ -12,7 +12,7 @@ pub(crate) async fn start_mode_command( root: &Path, state: &Arc>, args: Value, -) -> ToolResult { +) -> ToolOutcome { start_mode(root, state, args) } @@ -20,7 +20,7 @@ pub(crate) async fn stop_mode_command( _ctx: PluginCommandContext, root: &Path, state: &Arc>, -) -> ToolResult { +) -> ToolOutcome { stop_mode(root, state) } @@ -28,11 +28,15 @@ pub(crate) async fn clear_mode_command( _ctx: PluginCommandContext, root: &Path, state: &Arc>, -) -> ToolResult { +) -> ToolOutcome { clear_mode(root, state) } -pub(crate) fn start_mode(root: &Path, state: &Arc>, args: Value) -> ToolResult { +pub(crate) fn start_mode( + root: &Path, + state: &Arc>, + args: Value, +) -> ToolOutcome { let objective = args .get("objective") .and_then(Value::as_str) @@ -42,7 +46,7 @@ pub(crate) fn start_mode(root: &Path, state: &Arc>, args: Va let summary = { let mut state = match state.lock() { Ok(value) => value, - Err(_) => return ToolResult::err_fmt("autoresearch state poisoned"), + Err(_) => return ToolOutcome::err_fmt("autoresearch state poisoned"), }; state.touched = true; state.mode.active = true; @@ -51,7 +55,7 @@ pub(crate) fn start_mode(root: &Path, state: &Arc>, args: Va } let entries = match load_journal(root) { Ok(value) => value, - Err(err) => return ToolResult::err_fmt(err), + Err(err) => return ToolOutcome::err_fmt(err), }; compute_summary( &state.mode, @@ -61,51 +65,51 @@ pub(crate) fn start_mode(root: &Path, state: &Arc>, args: Va ) }; if let Err(err) = rewrite_markdown(root, &summary) { - return ToolResult::err_fmt(err); + return ToolOutcome::err_fmt(err); } let queued_input = objective.as_ref().map(|objective| { format!( "Start autoresearch.\nObjective: {objective}\nIf there is no active experiment segment yet, initialize one. Then make one concrete change, run a measurement, log the result, keep wins, discard regressions, and continue." ) }); - ToolResult::ok(json!({ + ToolOutcome::ok(json!({ "status": summary, "queued_input": queued_input, "message": "Autoresearch mode on." })) } -pub(crate) fn stop_mode(root: &Path, state: &Arc>) -> ToolResult { +pub(crate) fn stop_mode(root: &Path, state: &Arc>) -> ToolOutcome { let summary = { let mut state = match state.lock() { Ok(value) => value, - Err(_) => return ToolResult::err_fmt("autoresearch state poisoned"), + Err(_) => return ToolOutcome::err_fmt("autoresearch state poisoned"), }; state.mode.active = false; state.running = None; let entries = match load_journal(root) { Ok(value) => value, - Err(err) => return ToolResult::err_fmt(err), + Err(err) => return ToolOutcome::err_fmt(err), }; compute_summary(&state.mode, &entries, None, state.last_run.clone()) }; if let Err(err) = rewrite_markdown(root, &summary) { - return ToolResult::err_fmt(err); + return ToolOutcome::err_fmt(err); } - ToolResult::ok(json!({ + ToolOutcome::ok(json!({ "status": summary, "message": "Autoresearch mode off." })) } -pub(crate) fn clear_mode(root: &Path, state: &Arc>) -> ToolResult { +pub(crate) fn clear_mode(root: &Path, state: &Arc>) -> ToolOutcome { if let Err(err) = delete_session_files(root) { - return ToolResult::err_fmt(err); + return ToolOutcome::err_fmt(err); } let summary = { let mut state = match state.lock() { Ok(value) => value, - Err(_) => return ToolResult::err_fmt("autoresearch state poisoned"), + Err(_) => return ToolOutcome::err_fmt("autoresearch state poisoned"), }; state.touched = false; state.mode = ModeSnapshot::default(); @@ -113,41 +117,41 @@ pub(crate) fn clear_mode(root: &Path, state: &Arc>) -> ToolR state.last_run = None; StatusSummary::default() }; - ToolResult::ok(json!({ + ToolOutcome::ok(json!({ "status": summary, "message": "Cleared autoresearch session files." })) } -pub(crate) fn export_summary(root: &Path, state: &Arc>) -> ToolResult { +pub(crate) fn export_summary(root: &Path, state: &Arc>) -> ToolOutcome { let summary = match full_summary_from_runtime(root, state) { Ok(value) => value, - Err(err) => return ToolResult::err_fmt(err.to_string()), + Err(err) => return ToolOutcome::err_fmt(err.to_string()), }; match write_export_html(root, &summary) { - Ok(path) => ToolResult::ok(json!({ + Ok(path) => ToolOutcome::ok(json!({ "status": summary, "path": path.display().to_string(), "message": format!("Wrote {}.", EXPORT_FILE), })), - Err(err) => ToolResult::err_fmt(err), + Err(err) => ToolOutcome::err_fmt(err), } } -pub(crate) fn require_string(args: &Value, key: &str) -> Result { +pub(crate) fn require_string(args: &Value, key: &str) -> Result { args.get(key) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) - .ok_or_else(|| ToolResult::err_fmt(format_args!("missing required string `{key}`"))) + .ok_or_else(|| ToolOutcome::err_fmt(format_args!("missing required string `{key}`"))) } -pub(crate) fn require_f64(args: &Value, key: &str) -> Result { +pub(crate) fn require_f64(args: &Value, key: &str) -> Result { args.get(key) .and_then(Value::as_f64) .filter(|value| value.is_finite()) - .ok_or_else(|| ToolResult::err_fmt(format_args!("missing required number `{key}`"))) + .ok_or_else(|| ToolOutcome::err_fmt(format_args!("missing required number `{key}`"))) } pub(crate) fn parse_direction(value: Option<&str>) -> Result { diff --git a/crates/lash-autoresearch/src/runtime/summary.rs b/crates/lash-autoresearch/src/runtime/summary.rs index 1f47a69..3e7f49c 100644 --- a/crates/lash-autoresearch/src/runtime/summary.rs +++ b/crates/lash-autoresearch/src/runtime/summary.rs @@ -81,14 +81,14 @@ pub(crate) fn session_summary_from_runtime( compute_summary_from_state(root, state) } -pub(crate) fn status_tool_result(root: &Path, state: &Arc>) -> ToolResult { +pub(crate) fn status_tool_outcome(root: &Path, state: &Arc>) -> ToolOutcome { match session_summary_from_runtime(root, state) { - Ok(summary) => ToolResult::ok(json!(summary)), - Err(err) => ToolResult::err_fmt(err.to_string()), + Ok(summary) => ToolOutcome::ok(json!(summary)), + Err(err) => ToolOutcome::err_fmt(err.to_string()), } } -pub(crate) fn tool_result_output(result: ToolResult) -> Result +pub(crate) fn tool_outcome_output(result: ToolOutcome) -> Result where T: serde::de::DeserializeOwned, { diff --git a/crates/lash-autoresearch/src/runtime/tools.rs b/crates/lash-autoresearch/src/runtime/tools.rs index 9fc28e9..2e77c11 100644 --- a/crates/lash-autoresearch/src/runtime/tools.rs +++ b/crates/lash-autoresearch/src/runtime/tools.rs @@ -1,7 +1,7 @@ //! Autoresearch tool catalog: tool provider, definitions, and shell execution. use super::*; -use lash_lashlang_runtime::ToolDefinitionLashlangExt; +use lash_lashlang_runtime::{ToolBinding, ToolDefinitionBindingExt}; pub(crate) struct AutoresearchTools { pub(crate) workdir: PathBuf, @@ -18,7 +18,7 @@ pub(crate) fn autoresearch_tool( "init_experiment" => "init", "run_experiment" => "run", "log_experiment" => "log", - other => panic!("unknown autoresearch Lashlang tool binding for `{other}`"), + other => panic!("unknown autoresearch tool binding for `{other}`"), }; ToolDefinition::raw( format!("tool:{name}"), @@ -27,11 +27,7 @@ pub(crate) fn autoresearch_tool( input_schema, output_schema, ) - .with_lashlang_binding(lash_lashlang_runtime::LashlangToolBinding::new( - ["research"], - operation, - )) - .with_scheduling(ToolScheduling::Parallel) + .with_tool_binding(ToolBinding::new(["research"], operation)) } #[async_trait] @@ -50,15 +46,15 @@ impl ToolProvider for AutoresearchTools { .map(|tool| Arc::new(tool.contract())) } - async fn execute(&self, call: ToolCall<'_>) -> ToolResult { + async fn execute(&self, call: ToolCall<'_>) -> ToolOutcome { match call.name { "init_experiment" => self.execute_init(call.args), "log_experiment" => self.execute_log(call.args), "run_experiment" => { - self.execute_run(call.args, Some(call.context), call.progress) + self.execute_run(call.args, call.context.cancellation_token().cloned()) .await } - other => ToolResult::err_fmt(format_args!("unknown autoresearch tool `{other}`")), + other => ToolOutcome::err_fmt(format_args!("unknown autoresearch tool `{other}`")), } } } @@ -154,7 +150,7 @@ impl AutoresearchTools { ] } - fn execute_init(&self, args: &Value) -> ToolResult { + fn execute_init(&self, args: &Value) -> ToolOutcome { let name = match require_string(args, "name") { Ok(value) => value, Err(err) => return err, @@ -170,11 +166,11 @@ impl AutoresearchTools { .to_string(); let direction = match parse_direction(args.get("direction").and_then(Value::as_str)) { Ok(value) => value, - Err(err) => return ToolResult::err_fmt(err), + Err(err) => return ToolOutcome::err_fmt(err), }; let entries = match load_journal(&self.workdir) { Ok(value) => value, - Err(err) => return ToolResult::err_fmt(err), + Err(err) => return ToolOutcome::err_fmt(err), }; let next_segment = entries .iter() @@ -194,25 +190,25 @@ impl AutoresearchTools { direction, }); if let Err(err) = append_journal_entry(&self.workdir, &entry) { - return ToolResult::err_fmt(err); + return ToolOutcome::err_fmt(err); } if let Ok(mut state) = self.state.lock() { state.touched = true; } let summary = match self.summary() { Ok(value) => value, - Err(err) => return ToolResult::err_fmt(err), + Err(err) => return ToolOutcome::err_fmt(err), }; if let Err(err) = rewrite_markdown(&self.workdir, &summary) { - return ToolResult::err_fmt(err); + return ToolOutcome::err_fmt(err); } - ToolResult::ok(json!({ + ToolOutcome::ok(json!({ "segment": next_segment, "status": summary, })) } - fn execute_log(&self, args: &Value) -> ToolResult { + fn execute_log(&self, args: &Value) -> ToolOutcome { let commit = match require_string(args, "commit") { Ok(value) => value, Err(err) => return err, @@ -223,7 +219,7 @@ impl AutoresearchTools { }; let status = match parse_status(args.get("status").and_then(Value::as_str)) { Ok(value) => value, - Err(err) => return ToolResult::err_fmt(err), + Err(err) => return ToolOutcome::err_fmt(err), }; let description = match require_string(args, "description") { Ok(value) => value, @@ -231,17 +227,17 @@ impl AutoresearchTools { }; let metrics = match parse_metrics_object(args.get("metrics")) { Ok(value) => value, - Err(err) => return ToolResult::err_fmt(err), + Err(err) => return ToolOutcome::err_fmt(err), }; let mut entries = match load_journal(&self.workdir) { Ok(value) => value, - Err(err) => return ToolResult::err_fmt(err), + Err(err) => return ToolOutcome::err_fmt(err), }; let Some(config) = entries.iter().rev().find_map(|entry| match entry { JournalEntry::Config(config) => Some(config.clone()), JournalEntry::Result(_) => None, }) else { - return ToolResult::err_fmt("init_experiment must be called before log_experiment"); + return ToolOutcome::err_fmt("init_experiment must be called before log_experiment"); }; let current_results = entries .iter() @@ -255,7 +251,7 @@ impl AutoresearchTools { let confidence = confidence_for_candidate(&config, ¤t_results, metric); let state = match self.state.lock() { Ok(value) => value, - Err(_) => return ToolResult::err_fmt("autoresearch state poisoned"), + Err(_) => return ToolOutcome::err_fmt("autoresearch state poisoned"), }; let last_run = state.last_run.clone(); drop(state); @@ -274,7 +270,7 @@ impl AutoresearchTools { confidence, }); if let Err(err) = append_journal_entry(&self.workdir, &entry) { - return ToolResult::err_fmt(err); + return ToolOutcome::err_fmt(err); } if let Ok(mut state) = self.state.lock() { state.touched = true; @@ -283,7 +279,7 @@ impl AutoresearchTools { let summary = { let state = match self.state.lock() { Ok(value) => value, - Err(_) => return ToolResult::err_fmt("autoresearch state poisoned"), + Err(_) => return ToolOutcome::err_fmt("autoresearch state poisoned"), }; compute_summary( &state.mode, @@ -293,9 +289,9 @@ impl AutoresearchTools { ) }; if let Err(err) = rewrite_markdown(&self.workdir, &summary) { - return ToolResult::err_fmt(err); + return ToolOutcome::err_fmt(err); } - ToolResult::ok(json!({ + ToolOutcome::ok(json!({ "status": summary, })) } @@ -303,9 +299,8 @@ impl AutoresearchTools { async fn execute_run( &self, args: &Value, - context: Option<&ToolContext<'_>>, - progress: Option<&lash_core::ProgressSender>, - ) -> ToolResult { + cancellation_token: Option, + ) -> ToolOutcome { let command = match require_string(args, "command") { Ok(value) => value, Err(err) => return err, @@ -329,15 +324,14 @@ impl AutoresearchTools { &self.workdir, &command, timeout_seconds, - context.and_then(|value| value.cancellation_token().cloned()), - progress, + cancellation_token.clone(), ) .await { Ok(value) => value, Err(err) => { self.finish_run(None); - return ToolResult::err_fmt(err); + return ToolOutcome::err_fmt(err); } }; @@ -350,15 +344,14 @@ impl AutoresearchTools { &self.workdir, "bash autoresearch.checks.sh", checks_timeout_seconds, - context.and_then(|value| value.cancellation_token().cloned()), - None, + cancellation_token, ) .await { Ok(value) => Some(value), Err(err) => { self.finish_run(None); - return ToolResult::err_fmt(err); + return ToolOutcome::err_fmt(err); } } } else { @@ -383,7 +376,7 @@ impl AutoresearchTools { }; self.finish_run(Some(last_run.clone())); - ToolResult::ok(json!({ + ToolOutcome::ok(json!({ "command": command, "exit_code": run.exit_code, "duration_seconds": run.duration_seconds, @@ -514,7 +507,6 @@ pub(crate) async fn execute_shell_command( command: &str, timeout_seconds: u64, cancellation_token: Option, - progress: Option<&lash_core::ProgressSender>, ) -> Result { let shell = std::env::var("SHELL").unwrap_or_else(|_| "bash".to_string()); let mut child = Command::new(shell); @@ -532,7 +524,6 @@ pub(crate) async fn execute_shell_command( .stdout .take() .ok_or_else(|| "failed to capture experiment stdout".to_string())?; - let progress = progress.cloned(); let read_task = tokio::spawn(async move { let mut bytes = Vec::new(); let mut chunk = [0u8; 4096]; @@ -545,12 +536,6 @@ pub(crate) async fn execute_shell_command( break; } bytes.extend_from_slice(&chunk[..read]); - if let Some(progress) = progress.as_ref() { - let _ = progress.send(lash_core::SandboxMessage { - text: String::from_utf8_lossy(&chunk[..read]).into_owned(), - kind: "tool_output".to_string(), - }); - } } Ok::, String>(bytes) }); From 0f99d8b69f46aeae418690265c17dcb7da59c21b Mon Sep 17 00:00:00 2001 From: Samuel Galanakis Date: Sun, 23 Aug 2026 16:17:59 +0200 Subject: [PATCH 06/25] Port lash-export to current Lash stores Load sessions from the Lash main durable-core catalog through the current persistence facade, with schema preflight and typed refusal before open. Replace alpha-era projection and attachment shapes, derive model metadata and display titles from persisted current data, and drop unavailable created-at and cwd columns. Require a host-owned session roster for tree exports instead of scanning per-session databases. Cover JSON, single-session HTML, and tree HTML with sessions produced by a real current Lash runtime, and pin trace schema 8 plus old-schema refusal behavior. Release-Notes: Port lash-export to current Lash stores and export formats. --- crates/lash-export/Cargo.toml | 3 + crates/lash-export/src/bin/lash_export.rs | 21 +- crates/lash-export/src/html.rs | 57 +-- crates/lash-export/src/html/entries.rs | 4 +- crates/lash-export/src/html/entries/leaf.rs | 57 ++- crates/lash-export/src/html/prompt.rs | 4 +- crates/lash-export/src/html/session.rs | 37 +- crates/lash-export/src/html/stats.rs | 2 +- crates/lash-export/src/html/tree.rs | 39 +- crates/lash-export/src/json.rs | 28 +- crates/lash-export/src/lib.rs | 412 +++++++++++++++++--- crates/lash-export/src/trace.rs | 11 +- crates/lash-export/src/transcript.rs | 46 +-- crates/lash-export/src/tree.rs | 129 +++--- 14 files changed, 538 insertions(+), 312 deletions(-) diff --git a/crates/lash-export/Cargo.toml b/crates/lash-export/Cargo.toml index 5dd83be..8138aa6 100644 --- a/crates/lash-export/Cargo.toml +++ b/crates/lash-export/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true publish = false [dependencies] +lash = { workspace = true } lash-core = { workspace = true } lash-protocol-rlm = { workspace = true } lash-sqlite-store = { workspace = true } @@ -19,6 +20,8 @@ clap = { workspace = true, features = ["derive"] } tokio = { workspace = true, features = ["rt", "macros"] } [dev-dependencies] +lash = { workspace = true, features = ["testing"] } +rusqlite = { version = "0.37", features = ["bundled"] } tempfile = { workspace = true } [[bin]] diff --git a/crates/lash-export/src/bin/lash_export.rs b/crates/lash-export/src/bin/lash_export.rs index 34952d2..de33ad8 100644 --- a/crates/lash-export/src/bin/lash_export.rs +++ b/crates/lash-export/src/bin/lash_export.rs @@ -14,8 +14,16 @@ use lash_export::{ExportFormat, export}; about = "Render a persisted lash session as HTML or JSON" )] struct Cli { - /// Direct path to the session .db file. - db: PathBuf, + /// Root directory of the Lash SQLite session store. + store_root: PathBuf, + + /// Session to render as the root of the export. + #[arg(long)] + session_id: String, + + /// Host-roster session id to consider for tree HTML. May be repeated. + #[arg(long = "tree-session-id")] + tree_session_ids: Vec, /// Full provider trace JSONL for the session. #[arg(long)] @@ -48,7 +56,14 @@ fn run() -> Result<()> { .enable_all() .build() .with_context(|| "starting async runtime")? - .block_on(export(&cli.db, &cli.trace, format, cli.out.as_deref())) + .block_on(export( + &cli.store_root, + &cli.session_id, + &cli.tree_session_ids, + &cli.trace, + format, + cli.out.as_deref(), + )) .with_context(|| "rendering session")?; if cli.out.is_none() { diff --git a/crates/lash-export/src/html.rs b/crates/lash-export/src/html.rs index 5e93698..2f9fa89 100644 --- a/crates/lash-export/src/html.rs +++ b/crates/lash-export/src/html.rs @@ -29,8 +29,8 @@ mod tests { use super::*; use crate::LoadedSession; use crate::trace::{LlmCallUsage, LlmPromptSnapshot, RequestMessage}; - use lash_core::session_model::{Part, PartKind, PruneState, shared_parts}; - use lash_core::{ChronologicalEntry, ChronologicalPayload}; + use lash::persistence::{ChronologicalEntry, ChronologicalPayload}; + use lash_core::session_model::{Part, shared_parts}; use lash_rlm_types::RlmTrajectoryEntry; use std::path::PathBuf; @@ -64,18 +64,7 @@ mod tests { lash_core::session_model::Message { id: id.to_string(), role: lash_core::session_model::MessageRole::User, - parts: shared_parts(vec![Part { - id: format!("{id}.p0"), - kind: PartKind::Text, - content: text.to_string(), - attachment: None, - tool_call_id: None, - tool_name: None, - tool_replay: None, - prune_state: PruneState::Intact, - reasoning_meta: None, - response_meta: None, - }]), + parts: shared_parts(vec![Part::text(format!("{id}.p0"), text.to_string(), None)]), origin: None, } } @@ -84,18 +73,7 @@ mod tests { lash_core::session_model::Message { id: id.to_string(), role: lash_core::session_model::MessageRole::Assistant, - parts: shared_parts(vec![Part { - id: format!("{id}.p0"), - kind: PartKind::Text, - content: text.to_string(), - attachment: None, - tool_call_id: None, - tool_name: None, - tool_replay: None, - prune_state: PruneState::Intact, - reasoning_meta: None, - response_meta: None, - }]), + parts: shared_parts(vec![Part::text(format!("{id}.p0"), text.to_string(), None)]), origin: None, } } @@ -106,9 +84,7 @@ mod tests { protocol_iteration, code: "x = 1".to_string(), output: vec!["1".to_string()], - images: Vec::new(), - error: None, - final_output: None, + ..Default::default() } } @@ -140,6 +116,7 @@ mod tests { }, ], trace_path: PathBuf::from("session.trace.jsonl"), + model_id: None, context_window_tokens: None, llm_prompts: Vec::new(), }; @@ -197,18 +174,13 @@ mod tests { #[test] fn assistant_tool_call_message_part_renders_without_detached_tool_record() { - let tool_part = Part { - id: "m0.p0".to_string(), - kind: PartKind::ToolCall, - content: r#"{"q":"x"}"#.to_string(), - attachment: None, - tool_call_id: Some("call_1".to_string()), - tool_name: Some("lookup".to_string()), - tool_replay: None, - prune_state: PruneState::Intact, - reasoning_meta: None, - response_meta: None, - }; + let tool_part = Part::tool_call( + "m0.p0".to_string(), + r#"{"q":"x"}"#.to_string(), + "call_1".to_string(), + "lookup".to_string(), + None, + ); let assistant_msg = lash_core::session_model::Message { id: "m0".to_string(), role: lash_core::session_model::MessageRole::Assistant, @@ -222,6 +194,7 @@ mod tests { payload: ChronologicalPayload::Message(assistant_msg), }], trace_path: PathBuf::from("session.trace.jsonl"), + model_id: None, context_window_tokens: None, llm_prompts: Vec::new(), }; @@ -248,6 +221,7 @@ mod tests { }), }], trace_path: PathBuf::from("session.trace.jsonl"), + model_id: None, context_window_tokens: None, llm_prompts: Vec::new(), }; @@ -264,6 +238,7 @@ mod tests { meta: None, chronological: Vec::new(), trace_path: PathBuf::from("session.trace.jsonl"), + model_id: None, context_window_tokens: Some(100_000), llm_prompts: vec![LlmPromptSnapshot { usage: Some(LlmCallUsage { diff --git a/crates/lash-export/src/html/entries.rs b/crates/lash-export/src/html/entries.rs index 1b1e451..77f3ccd 100644 --- a/crates/lash-export/src/html/entries.rs +++ b/crates/lash-export/src/html/entries.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::fmt::Write as _; -use lash_core::ChronologicalPayload; +use lash::persistence::ChronologicalPayload; use lash_core::session_model::{Message, MessageRole, Part, PartKind, PruneState}; use super::escaping::{escape, escape_attr, json_highlight}; @@ -355,7 +355,7 @@ fn headline_for_message(message: &Message, user_text: Option<&str>) -> String { PartKind::Code => "code", PartKind::Output => "output", PartKind::Error => "error", - PartKind::Image => "image", + PartKind::Attachment => "attachment", PartKind::ToolCall => "tool_call", PartKind::ToolResult => "tool_result", PartKind::Reasoning => "reasoning", diff --git a/crates/lash-export/src/html/entries/leaf.rs b/crates/lash-export/src/html/entries/leaf.rs index 134b284..96c6097 100644 --- a/crates/lash-export/src/html/entries/leaf.rs +++ b/crates/lash-export/src/html/entries/leaf.rs @@ -3,7 +3,8 @@ use super::*; use crate::html::escaping::escape_breaks; -use lash_core::ChronologicalPayload; +use lash::direct::AttachmentSource; +use lash::persistence::ChronologicalPayload; pub(crate) fn render_part(out: &mut String, part: &Part) { match &part.prune_state { @@ -43,7 +44,7 @@ pub(crate) fn render_part(out: &mut String, part: &Part) { PartKind::Code => render_code(out, "code", &part.content, None), PartKind::Output => render_code(out, "output", &part.content, Some("output")), PartKind::Error => render_code(out, "error", &part.content, Some("error")), - PartKind::Image => render_image_part(out, part), + PartKind::Attachment => render_attachment_part(out, part), PartKind::ToolCall => render_tool_protocol_part(out, part, "tool_call"), PartKind::ToolResult => render_tool_protocol_part(out, part, "tool_result"), PartKind::Reasoning => render_reasoning(out, &part.content), @@ -97,8 +98,8 @@ pub(crate) fn first_n_chars(s: &str, n: usize) -> String { } pub(crate) fn pick_display_title(session: &LoadedSession, name: &str, id: &str) -> String { - // If the session_name is the session_id (a UUID), or empty, look for a - // more useful title in the transcript: the first user message's first + // Prefer a supplied label unless it is empty or merely the session id; + // otherwise derive a useful title from the first user message's first // line. Some user messages have UserInputProvenance set; others don't, // so fall back to assembling text from Text/Prose parts. let name_trim = name.trim(); @@ -207,28 +208,50 @@ pub(crate) fn render_code(out: &mut String, class: &str, content: &str, badge: O } } -fn render_image_part(out: &mut String, part: &Part) { - let label = part +fn render_attachment_part(out: &mut String, part: &Part) { + let source = part .attachment .as_ref() - .and_then(|a| a.reference.label.clone()) - .unwrap_or_else(|| { - if part.content.trim().is_empty() { - "image attached".to_string() + .map(|attachment| &attachment.source); + let kind = source + .and_then(AttachmentSource::media_type) + .map_or("attachment", |media_type| { + if media_type.is_image() { + "image" } else { - part.content.clone() + "attachment" } }); - let aref = part - .attachment - .as_ref() - .map(|a| a.reference.id.to_string()) + let source_label = source.map(|source| match source { + AttachmentSource::Inline { media_type, bytes } => { + format!("{media_type} · {} bytes", bytes.len()) + } + AttachmentSource::Stored { attachment_ref } => attachment_ref + .label + .clone() + .unwrap_or_else(|| attachment_ref.id.to_string()), + AttachmentSource::ExternalUrl { url, .. } => url.clone(), + AttachmentSource::ProviderFile { id, .. } => id.clone(), + }); + let label = if part.content.trim().is_empty() { + source_label.unwrap_or_else(|| format!("{kind} attached")) + } else { + part.content.clone() + }; + let reference = source + .map(|source| match source { + AttachmentSource::Inline { media_type, .. } => media_type.to_string(), + AttachmentSource::Stored { attachment_ref } => attachment_ref.id.to_string(), + AttachmentSource::ExternalUrl { url, .. } => url.clone(), + AttachmentSource::ProviderFile { id, .. } => id.clone(), + }) .unwrap_or_default(); let _ = writeln!( out, - "
image{}{}
", + "
{}{}{}
", + escape(kind), escape(&label), - escape(&aref) + escape(&reference) ); } diff --git a/crates/lash-export/src/html/prompt.rs b/crates/lash-export/src/html/prompt.rs index 5489bef..24c39f6 100644 --- a/crates/lash-export/src/html/prompt.rs +++ b/crates/lash-export/src/html/prompt.rs @@ -1,7 +1,7 @@ use std::collections::{HashMap, VecDeque}; use std::fmt::Write as _; -use lash_core::ChronologicalPayload; +use lash::persistence::{ChronologicalEntry, ChronologicalPayload}; use lash_core::session_model::MessageRole; use crate::trace::LlmPromptSnapshot; @@ -86,7 +86,7 @@ pub(crate) fn compute_coalesce_states( } pub(crate) fn compute_prompt_insertions( - chronological: &[lash_core::ChronologicalEntry], + chronological: &[ChronologicalEntry], prompts: &[LlmPromptSnapshot], ) -> PromptInsertions { let mut before_index = vec![Vec::new(); chronological.len()]; diff --git a/crates/lash-export/src/html/session.rs b/crates/lash-export/src/html/session.rs index cc1eec5..e2c03ca 100644 --- a/crates/lash-export/src/html/session.rs +++ b/crates/lash-export/src/html/session.rs @@ -17,7 +17,7 @@ pub fn render(session: &LoadedSession) -> String { let title = session .meta .as_ref() - .map(|meta| meta.session_name.clone()) + .map(|meta| pick_display_title(session, "", &meta.session_id)) .unwrap_or_else(|| "lash session".to_string()); out.push_str("\n\n\n"); @@ -48,27 +48,16 @@ pub fn render(session: &LoadedSession) -> String { // ─── stats ────────────────────────────────────────────────────────────────── pub(crate) fn write_hero(out: &mut String, session: &LoadedSession, _stats: &SessionStats) { - let (name, model, id, created, cwd, parent) = if let Some(meta) = &session.meta { + let (id, parent) = if let Some(meta) = &session.meta { ( - meta.session_name.clone(), - meta.model.clone(), meta.session_id.clone(), - meta.created_at.clone(), - meta.cwd.clone(), meta.parent_session_id().map(ToOwned::to_owned), ) } else { - ( - "lash session".to_string(), - String::new(), - String::new(), - String::new(), - None, - None, - ) + (String::new(), None) }; - let display_title = pick_display_title(session, &name, &id); + let display_title = pick_display_title(session, "", &id); out.push_str("
\n"); out.push_str("
\n"); @@ -88,25 +77,11 @@ pub(crate) fn write_hero(out: &mut String, session: &LoadedSession, _stats: &Ses escape(&id) ); } - if !model.is_empty() { + if let Some(model) = &session.model_id { let _ = writeln!( out, " model{}", - escape(&model) - ); - } - if !created.is_empty() { - let _ = writeln!( - out, - " created{}", - escape(&created) - ); - } - if let Some(cwd) = cwd { - let _ = writeln!( - out, - " cwd{}", - escape(&cwd) + escape(model) ); } if let Some(parent) = parent { diff --git a/crates/lash-export/src/html/stats.rs b/crates/lash-export/src/html/stats.rs index 23c06d2..48a20cd 100644 --- a/crates/lash-export/src/html/stats.rs +++ b/crates/lash-export/src/html/stats.rs @@ -1,4 +1,4 @@ -use lash_core::ChronologicalPayload; +use lash::persistence::ChronologicalPayload; use lash_core::session_model::{MessageRole, PruneState}; use crate::LoadedSession; diff --git a/crates/lash-export/src/html/tree.rs b/crates/lash-export/src/html/tree.rs index fc2dd8e..2ef086a 100644 --- a/crates/lash-export/src/html/tree.rs +++ b/crates/lash-export/src/html/tree.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::fmt::Write as _; -use lash_core::ChronologicalPayload; +use lash::persistence::ChronologicalPayload; use crate::LoadedSession; use crate::transcript::{ @@ -11,8 +11,8 @@ use crate::tree::{LoadedSessionNode, LoadedSessionTree, NodeRelation}; use super::assets::{CSS, JS}; use super::entries::{ - render_assistant_reasoning_entry, render_assistant_text_entry, render_lashlang_step, - render_message, + pick_display_title, render_assistant_reasoning_entry, render_assistant_text_entry, + render_lashlang_step, render_message, }; use super::escaping::{escape, escape_attr, js_escape}; use super::prompt::{ @@ -25,7 +25,8 @@ use super::view_model::{RenderCtx, one_line_summary}; pub fn render_tree(tree: &LoadedSessionTree) -> String { let mut out = String::with_capacity(128 * 1024); - let title = tree.root().meta.session_name.clone(); + let root_session = node_as_session(tree.root()); + let title = pick_display_title(&root_session, "", &tree.root().meta.session_id); out.push_str("\n\n\n"); out.push_str("\n"); out.push_str("\n"); @@ -186,6 +187,7 @@ fn node_as_session(node: &LoadedSessionNode) -> LoadedSession { meta: Some(node.meta.clone()), chronological: node.chronological.clone(), trace_path: node.db_path.clone(), + model_id: node.model_id.clone(), context_window_tokens: node.context_window_tokens, llm_prompts: node.llm_prompts.clone(), } @@ -198,18 +200,16 @@ fn write_view_hero( chain: &[&LoadedSessionNode], ) { let meta = &head.meta; - let display_title = - if meta.session_name.trim().is_empty() || meta.session_name == meta.session_id { - match &head.kind { - NodeRelation::Root => "lash session".to_string(), - NodeRelation::Subagent { task, .. } => task - .as_deref() - .map(|task| one_line_summary(task, 80)) - .unwrap_or_else(|| "subagent".to_string()), - } - } else { - meta.session_name.clone() - }; + let display_title = match &head.kind { + NodeRelation::Root => { + let session = node_as_session(head); + pick_display_title(&session, "", &meta.session_id) + } + NodeRelation::Subagent { task, .. } => task + .as_deref() + .map(|task| one_line_summary(task, 80)) + .unwrap_or_else(|| "subagent".to_string()), + }; let role_label = match &head.kind { NodeRelation::Root => "root session".to_string(), NodeRelation::Subagent { @@ -253,11 +253,8 @@ fn write_view_hero( "view", &format!("{} · {}", short_session_id(&meta.session_id), view_label), ); - if !meta.model.is_empty() { - write_meta_row(out, "model", &meta.model); - } - if let Some(cwd) = &meta.cwd { - write_meta_row(out, "cwd", cwd); + if let Some(model) = &head.model_id { + write_meta_row(out, "model", model); } let subagent_count: usize = chain.iter().map(|n| n.subagent_children.len()).sum(); if subagent_count > 0 { diff --git a/crates/lash-export/src/json.rs b/crates/lash-export/src/json.rs index 3fd3fcf..e1ae72a 100644 --- a/crates/lash-export/src/json.rs +++ b/crates/lash-export/src/json.rs @@ -8,10 +8,6 @@ pub fn render(session: &LoadedSession) -> String { let meta = session.meta.as_ref().map(|meta| { json!({ "session_id": meta.session_id, - "session_name": meta.session_name, - "created_at": meta.created_at, - "model": meta.model, - "cwd": meta.cwd, "parent_session_id": meta.parent_session_id(), "relation": meta.relation, }) @@ -19,6 +15,7 @@ pub fn render(session: &LoadedSession) -> String { let document = json!({ "meta": meta, + "model_id": session.model_id, "trace_path": session.trace_path.display().to_string(), "context_window_tokens": session.context_window_tokens, "chronological": session.chronological, @@ -31,9 +28,8 @@ pub fn render(session: &LoadedSession) -> String { #[cfg(test)] mod tests { use super::*; - use lash_core::{ - ChronologicalEntry, ChronologicalPayload, Message, MessageRole, PruneState, shared_parts, - }; + use lash::persistence::{ChronologicalEntry, ChronologicalPayload}; + use lash_core::{Message, MessageRole}; use std::path::PathBuf; #[test] @@ -45,22 +41,16 @@ mod tests { payload: ChronologicalPayload::Message(Message { id: "m1".to_string(), role: MessageRole::User, - parts: shared_parts(vec![lash_core::Part { - id: "p1".to_string(), - kind: lash_core::PartKind::Text, - content: "hello".to_string(), - attachment: None, - tool_call_id: None, - tool_name: None, - tool_replay: None, - prune_state: PruneState::Intact, - reasoning_meta: None, - response_meta: None, - }]), + parts: std::sync::Arc::new(vec![lash_core::Part::text( + "p1".to_string(), + "hello".to_string(), + None, + )]), origin: None, }), }], trace_path: PathBuf::from("session.trace.jsonl"), + model_id: None, context_window_tokens: None, llm_prompts: Vec::new(), }; diff --git a/crates/lash-export/src/lib.rs b/crates/lash-export/src/lib.rs index f318822..3984997 100644 --- a/crates/lash-export/src/lib.rs +++ b/crates/lash-export/src/lib.rs @@ -4,12 +4,20 @@ //! store, projects the `SessionGraph` into committed messages and protocol //! events, and writes a self-contained HTML (or JSON) document. +use std::error::Error; +use std::fmt; use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow}; -use lash_core::{ChronologicalEntry, SessionGraph, SessionMeta, SessionSnapshot}; -use lash_sqlite_store::Store; +use lash::TurnBudget; +use lash::persistence::{ + ChronologicalEntry, RuntimePersistence, SessionMeta, SessionRelation, + SessionStoreCreateRequest, SessionStoreFactory, +}; +use lash::preflight::{PreflightOptions, PreflightOutcome, probe_store}; +use lash::runtime::{SessionPolicy, SessionSnapshot}; +use lash_sqlite_store::{SqliteSessionStoreFactory, SqliteStorePreflight}; pub mod html; pub mod json; @@ -46,41 +54,180 @@ pub struct LoadedSession { pub meta: Option, pub chronological: Vec, pub trace_path: PathBuf, + /// Derived from the current persisted session-head policy. + pub model_id: Option, pub context_window_tokens: Option, /// One snapshot per `llm_call_started` event found in the required /// provider trace, in trace order. pub llm_prompts: Vec, } -/// Load a session by its Sqlite store path and full provider trace path. +/// One SQLite schema stamp that refuses the current Lash build. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SchemaStampMismatch { + pub database: String, + pub found: i64, + pub expected: i64, +} + +/// A typed session-load failure. +#[derive(Debug)] +pub enum LoadSessionError { + IncompatibleStore { + store_root: PathBuf, + stamps: Vec, + message: String, + }, + PreflightUndecided { + store_root: PathBuf, + message: String, + }, + Preflight(lash::persistence::StoreError), + OpenStore(String), + SessionNotFound(String), + Store(lash::persistence::StoreError), + Trace(anyhow::Error), +} + +impl fmt::Display for LoadSessionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::IncompatibleStore { message, .. } => f.write_str(message), + Self::PreflightUndecided { message, .. } => f.write_str(message), + Self::Preflight(error) => write!(f, "preflighting session store: {error}"), + Self::OpenStore(error) => write!(f, "opening existing session store: {error}"), + Self::SessionNotFound(session_id) => { + write!(f, "session `{session_id}` was not found in the store") + } + Self::Store(error) => write!(f, "reading session store: {error}"), + Self::Trace(error) => write!(f, "reading provider trace: {error:#}"), + } + } +} + +impl Error for LoadSessionError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Preflight(error) | Self::Store(error) => Some(error), + Self::Trace(error) => Some(error.as_ref()), + Self::IncompatibleStore { .. } + | Self::PreflightUndecided { .. } + | Self::OpenStore(_) + | Self::SessionNotFound(_) => None, + } + } +} + +/// Load a session from a Lash main SQLite store root and full provider trace. pub async fn load_session_from_paths( - store_path: &Path, + store_root: &Path, + session_id: &str, trace_path: &Path, -) -> Result { - let store = Store::open_readonly(store_path) +) -> std::result::Result { + preflight_store(store_root).await?; + let store = open_session(store_root, session_id).await?; + let mut loaded = load_session(&*store, session_id).await?; + loaded.trace_path = trace_path.to_path_buf(); + loaded.llm_prompts = + trace::load_prompts_from_trace(trace_path).map_err(LoadSessionError::Trace)?; + Ok(loaded) +} + +pub(crate) async fn preflight_store( + store_root: &Path, +) -> std::result::Result<(), LoadSessionError> { + let preflight = SqliteStorePreflight::for_session_store_root(store_root); + let report = probe_store(&preflight, PreflightOptions::summary()) .await - .with_context(|| format!("opening session store at {}", store_path.display()))?; - let meta = store.load_session_meta().await; - let head = store.load_session_head().await; - let context_window_tokens = head - .as_ref() - .map(|head| head.config.model.context_window_tokens() as u64); - let graph = match head { - Some(head) => head.graph, - None => load_graph(&store).await, + .map_err(LoadSessionError::Preflight)?; + match report.outcome { + PreflightOutcome::Ready => return Ok(()), + PreflightOutcome::Undecided => { + return Err(LoadSessionError::PreflightUndecided { + store_root: store_root.to_path_buf(), + message: report.to_string(), + }); + } + PreflightOutcome::Refused => {} + _ => { + return Err(LoadSessionError::PreflightUndecided { + store_root: store_root.to_path_buf(), + message: report.to_string(), + }); + } + } + let stamps = report + .schema + .databases + .iter() + .filter(|database| database.verdict == "mismatch") + .filter_map(|database| { + database.found.map(|found| SchemaStampMismatch { + database: database.name.to_string(), + found, + expected: database.expected, + }) + }) + .collect(); + let message = report + .refusal_message() + .unwrap_or_else(|| report.to_string()); + Err(LoadSessionError::IncompatibleStore { + store_root: store_root.to_path_buf(), + stamps, + message, + }) +} + +pub(crate) async fn open_session( + store_root: &Path, + session_id: &str, +) -> std::result::Result, LoadSessionError> { + let factory = SqliteSessionStoreFactory::new(store_root); + let request = SessionStoreCreateRequest { + session_id: session_id.to_string(), + relation: SessionRelation::Root, + policy: SessionPolicy::new(TurnBudget::Unbounded), }; - let state = SessionSnapshot { - session_graph: graph, - ..SessionSnapshot::default() + factory + .open_existing_store(&request) + .await + .map_err(LoadSessionError::OpenStore)? + .ok_or_else(|| LoadSessionError::SessionNotFound(session_id.to_string())) +} + +pub(crate) async fn load_session( + store: &dyn RuntimePersistence, + session_id: &str, +) -> std::result::Result { + let meta = store + .load_session_meta() + .await + .map_err(LoadSessionError::Store)?; + let persisted = store + .load_session() + .await + .map_err(LoadSessionError::Store)? + .ok_or_else(|| LoadSessionError::SessionNotFound(session_id.to_string()))?; + let (model_id, context_window_tokens) = { + let model = &persisted.config.model; + ( + Some(model.id.clone()), + Some(model.context_window_tokens() as u64), + ) }; + let mut state = SessionSnapshot::new(SessionPolicy::new(TurnBudget::Unbounded)); + state.session_id = session_id.to_string(); + state.current_frame_node_id = persisted.current_frame_node_id; + state.session_graph = persisted.graph; let chronological = state.read_view().chronological_projection().into_entries(); - let llm_prompts = trace::load_prompts_from_trace(trace_path)?; Ok(LoadedSession { meta, chronological, - trace_path: trace_path.to_path_buf(), + trace_path: PathBuf::new(), + model_id, context_window_tokens, - llm_prompts, + llm_prompts: Vec::new(), }) } @@ -97,52 +244,48 @@ pub fn render(session: &LoadedSession, format: ExportFormat) -> String { pub fn render_tree(tree: &LoadedSessionTree, format: ExportFormat) -> String { match format { ExportFormat::Html => html::render_tree(tree), - ExportFormat::Json => { - let root_session = LoadedSession { - meta: Some(tree.root().meta.clone()), - chronological: tree.root().chronological.clone(), - trace_path: tree.trace_path.clone(), - context_window_tokens: tree.root().context_window_tokens, - llm_prompts: tree.root().llm_prompts.clone(), - }; - json::render(&root_session) - } + ExportFormat::Json => json::render(&loaded_tree_root(tree)), + } +} + +fn loaded_tree_root(tree: &LoadedSessionTree) -> LoadedSession { + LoadedSession { + meta: Some(tree.root().meta.clone()), + chronological: tree.root().chronological.clone(), + trace_path: tree.trace_path.clone(), + model_id: tree.root().model_id.clone(), + context_window_tokens: tree.root().context_window_tokens, + llm_prompts: tree.root().llm_prompts.clone(), } } -/// End-to-end: load a session DB plus full provider trace and write the +/// End-to-end: load a session from the store root plus its full provider trace and write the /// rendered output to disk. If `out` is `None`, returns the rendered string /// instead of writing it. /// -/// Multi-session aware: walks the sessions directory next to `store_path` -/// for descendants reachable via `parent_session_id`. If any are found the -/// html exporter renders them as a tree of views with breadcrumb navigation; -/// otherwise it falls back to single-session rendering. +/// For tree HTML, `session_ids` is the host-owned session roster to consider. +/// Descendants reachable from `root_session_id` render as a tree of views with +/// breadcrumb navigation; a root-only roster renders as a single session. pub async fn export( - store_path: &Path, + store_root: &Path, + root_session_id: &str, + session_ids: &[String], trace_path: &Path, format: ExportFormat, out: Option<&Path>, ) -> Result { let rendered = match format { ExportFormat::Html => { - // Try multi-session first; fall back to single-session for - // sessions whose .db isn't co-located in a sessions dir - // (e.g. ad-hoc per-run artifacts under .benchmarks/...). - match load_tree_from_paths(store_path, trace_path).await { - Ok(tree) if tree.nodes.len() > 1 => render_tree(&tree, format), - Ok(_) => { - let session = load_session_from_paths(store_path, trace_path).await?; - render(&session, format) - } - Err(_) => { - let session = load_session_from_paths(store_path, trace_path).await?; - render(&session, format) - } + let tree = + load_tree_from_paths(store_root, root_session_id, session_ids, trace_path).await?; + if tree.nodes.len() > 1 { + render_tree(&tree, format) + } else { + render(&loaded_tree_root(&tree), format) } } ExportFormat::Json => { - let session = load_session_from_paths(store_path, trace_path).await?; + let session = load_session_from_paths(store_root, root_session_id, trace_path).await?; render(&session, format) } }; @@ -153,11 +296,166 @@ pub async fn export( Ok(rendered) } -async fn load_graph(store: &Store) -> SessionGraph { - if let Some(head) = store.load_session_head().await { - return head.graph; +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use lash::direct::LlmOutputPart; + use lash::provider::LlmResponse; + use lash::{LashCore, ModelSpec, TurnInput}; + + use super::*; + + fn runtime_builder(store_root: &Path, trace_path: &Path) -> lash::LashCoreBuilder { + let provider = lash::testing::TestProvider::builder() + .kind("lash-export-fixture") + .complete(|_request| async move { + let text = "runtime fixture answer".to_string(); + Ok(LlmResponse { + full_text: text.clone(), + parts: vec![LlmOutputPart::Text { + text, + response_meta: None, + }], + response_metadata: Default::default(), + ..Default::default() + }) + }) + .build() + .into_handle(); + LashCore::standard_builder(TurnBudget::Unbounded) + .provider(provider) + .model( + ModelSpec::builder("fixture-model") + .context_window_tokens(4_096) + .build() + .expect("valid fixture model"), + ) + .store_factory(Arc::new(SqliteSessionStoreFactory::new(store_root))) + .effect_host(Arc::new(lash::durability::InlineEffectHost::default())) + .attachment_store(Arc::new(lash::persistence::InMemoryAttachmentStore::new())) + .process_env_store(Arc::new( + lash::persistence::InMemoryProcessExecutionEnvStore::new(), + )) + .commit_budget(lash::CommitBudget::bounded(1024 * 1024, 512)) + .queued_work_batching(lash::QueuedWorkBatchingConfig::new(1)) + .trace_jsonl_path(trace_path) + } + + #[tokio::test] + async fn exports_current_runtime_sessions_in_all_formats() { + let temp = tempfile::tempdir().expect("tempdir"); + let store_root = temp.path().join("sessions"); + let trace_path = temp.path().join("trace.jsonl"); + let core = runtime_builder(&store_root, &trace_path) + .build(lash::persistence::LeaseOwnerIdentity::opaque( + "lash-export-test", + "lash-export-test:boot", + )) + .expect("build current Lash runtime"); + + let root = core + .session("root-session") + .open() + .await + .expect("open root"); + root.turn(TurnInput::text("root prompt")) + .run() + .await + .expect("run root turn"); + let child = core + .session("child-session") + .parent("root-session") + .open() + .await + .expect("open child"); + child + .turn(TurnInput::text("child prompt")) + .run() + .await + .expect("run child turn"); + core.flush_trace_sink().expect("flush trace"); + + let json = export( + &store_root, + "root-session", + &[], + &trace_path, + ExportFormat::Json, + None, + ) + .await + .expect("export current JSON"); + assert!(json.contains("root prompt")); + assert!(json.contains("runtime fixture answer")); + assert!(json.contains("fixture-model")); + + let html = export( + &store_root, + "root-session", + &[], + &trace_path, + ExportFormat::Html, + None, + ) + .await + .expect("export current single-session HTML"); + assert!(html.contains("root prompt")); + assert!(html.contains("runtime fixture answer")); + assert!(html.contains("fixture-model")); + + let tree = export( + &store_root, + "root-session", + &["child-session".to_string()], + &trace_path, + ExportFormat::Html, + None, + ) + .await + .expect("export current tree HTML"); + assert!(tree.contains("root prompt")); + assert!(tree.contains("child prompt")); + assert!(tree.contains("runtime fixture answer")); + } + + #[tokio::test] + async fn old_schema_stamp_returns_typed_refusal() { + let temp = tempfile::tempdir().expect("tempdir"); + let store_root = temp.path().join("sessions"); + std::fs::create_dir_all(&store_root).expect("create store root"); + let database_path = store_root.join("durable-core.db"); + let connection = rusqlite::Connection::open(&database_path).expect("open empty database"); + connection + .pragma_update(None, "user_version", 11_i64) + .expect("stamp old schema"); + drop(connection); + + let error = match load_session_from_paths( + &store_root, + "old-session", + &temp.path().join("unused.trace.jsonl"), + ) + .await + { + Ok(_) => panic!("old schema must be refused before session or trace loading"), + Err(error) => error, + }; + match error { + LoadSessionError::IncompatibleStore { + store_root: refused_root, + stamps, + message, + } => { + assert_eq!(refused_root, store_root); + assert_eq!(stamps.len(), 1); + assert_eq!(stamps[0].database, "durable core"); + assert_eq!(stamps[0].found, 11); + assert_eq!(stamps[0].expected, 38); + assert!(message.contains("11")); + assert!(message.contains("38")); + } + other => panic!("expected typed schema refusal, got {other}"), + } } - // No committed head but graph nodes may still have been appended — - // read the raw node stream directly. - store.load_session_graph().await } diff --git a/crates/lash-export/src/trace.rs b/crates/lash-export/src/trace.rs index 6e857c3..25748ea 100644 --- a/crates/lash-export/src/trace.rs +++ b/crates/lash-export/src/trace.rs @@ -274,6 +274,11 @@ fn short_hash(text: &str) -> String { #[cfg(test)] mod tests { use super::*; + + #[test] + fn consumes_current_trace_schema() { + assert_eq!(lash_trace::TRACE_SCHEMA_VERSION, 8); + } use lash_trace::{TraceContext, TraceLlmMessage, TraceRecord}; use std::io::Write; @@ -348,6 +353,7 @@ mod tests { duration_ms: 1234, terminal_reason: None, parts: None, + generation_disposition: None, }, usage: Some(TraceTokenUsage { input_tokens: 100, @@ -358,6 +364,7 @@ mod tests { }), provider_usage: None, stream_summary: None, + attempts: None, }, ); write_records(&mut tmp, &[started, completed]); @@ -422,7 +429,7 @@ mod tests { writeln!(tmp, "not json").unwrap(); writeln!( tmp, - r#"{{"schema_version":2,"id":"x","timestamp":"t","context":{{}},"type":"future_event","payload":{{}}}}"# + r#"{{"schema_version":9,"id":"x","timestamp":"t","context":{{}},"type":"future_event","payload":{{}}}}"# ) .unwrap(); let started = TraceRecord::new( @@ -471,6 +478,7 @@ mod tests { duration_ms: 1, terminal_reason: None, parts: None, + generation_disposition: None, }, usage: Some(TraceTokenUsage { input_tokens: input, @@ -481,6 +489,7 @@ mod tests { }), provider_usage: None, stream_summary: None, + attempts: None, }, )); } diff --git a/crates/lash-export/src/transcript.rs b/crates/lash-export/src/transcript.rs index 016f15a..f6c9dc7 100644 --- a/crates/lash-export/src/transcript.rs +++ b/crates/lash-export/src/transcript.rs @@ -1,7 +1,7 @@ use std::collections::HashSet; +use lash::persistence::{ChronologicalEntry, ChronologicalPayload, ChronologicalProjection}; use lash_core::session_model::{Message, MessageRole, PartKind, ProtocolEvent}; -use lash_core::{ChronologicalEntry, ChronologicalPayload, ChronologicalProjection}; #[derive(Clone, Debug, PartialEq)] pub struct LashlangTranscriptStep { @@ -230,7 +230,7 @@ pub fn format_tokens(n: i64) -> String { mod tests { use std::sync::Arc; - use lash_core::session_model::{Part, PruneState}; + use lash_core::session_model::Part; use super::*; @@ -238,18 +238,7 @@ mod tests { Message { id: id.to_string(), role, - parts: Arc::new(vec![Part { - id: format!("{id}.p0"), - kind: PartKind::Text, - content: text.to_string(), - attachment: None, - tool_call_id: None, - tool_name: None, - tool_replay: None, - prune_state: PruneState::Intact, - reasoning_meta: None, - response_meta: None, - }]), + parts: Arc::new(vec![Part::text(format!("{id}.p0"), text.to_string(), None)]), origin: None, } } @@ -259,30 +248,8 @@ mod tests { id: id.to_string(), role: MessageRole::Assistant, parts: Arc::new(vec![ - Part { - id: format!("{id}.r"), - kind: PartKind::Reasoning, - content: reasoning.to_string(), - attachment: None, - tool_call_id: None, - tool_name: None, - tool_replay: None, - prune_state: PruneState::Intact, - reasoning_meta: None, - response_meta: None, - }, - Part { - id: format!("{id}.t"), - kind: PartKind::Text, - content: text.to_string(), - attachment: None, - tool_call_id: None, - tool_name: None, - tool_replay: None, - prune_state: PruneState::Intact, - reasoning_meta: None, - response_meta: None, - }, + Part::reasoning(format!("{id}.r"), reasoning.to_string(), None), + Part::text(format!("{id}.t"), text.to_string(), None), ]), origin: None, } @@ -302,9 +269,8 @@ mod tests { protocol_iteration: 3, code: "answer = 1".to_string(), output: vec!["1".to_string()], - images: Vec::new(), - error: None, final_output, + ..Default::default() } } diff --git a/crates/lash-export/src/tree.rs b/crates/lash-export/src/tree.rs index fe983da..2f674b3 100644 --- a/crates/lash-export/src/tree.rs +++ b/crates/lash-export/src/tree.rs @@ -1,8 +1,8 @@ -//! Multi-session export — discover descendant sessions reachable from a -//! root `.db` and classify cross-session edges created by `spawn_agent`. +//! Multi-session export — load host-rostered descendants reachable from a +//! root session and classify cross-session edges created by `spawn_agent`. //! -//! The on-disk shape is one `.db` per session in `sessions_dir/`, each -//! carrying its full session relation in `session_meta`. For subagents, +//! Lash main keeps sessions in one durable-core catalog. Each session carries +//! its full relation in `session_meta`. For subagents, //! `SessionRelation::Child.caused_by` anchors the child to //! the parent `spawn_agent` call without relying on model-authored names. //! @@ -10,20 +10,19 @@ //! record carries `context.session_id`; we partition `LlmPromptSnapshot`s //! by that field so each session in the tree gets its matching prompts. -use std::collections::HashMap; -use std::fs; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; -use anyhow::{Context, Result, anyhow}; -use lash_core::{ChronologicalEntry, SessionGraph, SessionMeta}; -use lash_sqlite_store::Store; +use lash::persistence::{ChronologicalEntry, SessionMeta, SessionRelation}; use crate::trace::{LlmPromptSnapshot, load_prompts_from_trace}; +use crate::{LoadSessionError, load_session, open_session, preflight_store}; /// One session in the tree, ready to render. pub struct LoadedSessionNode { pub meta: SessionMeta, pub chronological: Vec, + pub model_id: Option, pub context_window_tokens: Option, pub llm_prompts: Vec, pub db_path: PathBuf, @@ -66,6 +65,7 @@ struct CandidateLoad { db_path: PathBuf, meta: SessionMeta, chronological: Vec, + model_id: Option, context_window_tokens: Option, } @@ -106,13 +106,19 @@ impl LoadedSessionTree { } } -/// Discover descendant sessions starting from `root_db` and load the tree. +/// Load the root and the host-supplied roster, retaining descendants of root. /// /// `trace_path` may cover any subset of sessions in the tree; prompts are /// partitioned by `context.session_id`. Sessions for which no prompts are /// found in the trace render fine — they just don't show LLM-call rows. -pub async fn load_tree_from_paths(root_db: &Path, trace_path: &Path) -> Result { - let prompts_all = load_prompts_from_trace(trace_path)?; +pub async fn load_tree_from_paths( + store_root: &Path, + root_session_id: &str, + session_ids: &[String], + trace_path: &Path, +) -> Result { + preflight_store(store_root).await?; + let prompts_all = load_prompts_from_trace(trace_path).map_err(LoadSessionError::Trace)?; let mut prompts_by_session: HashMap> = HashMap::new(); let mut prompts_unkeyed: Vec = Vec::new(); for prompt in prompts_all { @@ -122,49 +128,36 @@ pub async fn load_tree_from_paths(root_db: &Path, trace_path: &Path) -> Result = Vec::new(); - let entries = fs::read_dir(&root_dir) - .with_context(|| format!("scanning sessions dir {}", root_dir.display()))?; - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|s| s.to_str()) != Some("db") { - continue; - } - let Ok(store) = Store::open_readonly(&path).await else { - continue; - }; - let Some(meta) = store.load_session_meta().await else { - continue; - }; - let head = store.load_session_head().await; - let context_window_tokens = head - .as_ref() - .map(|h| h.config.model.context_window_tokens() as u64); - let graph = match head { - Some(head) => head.graph, - None => store.load_session_graph().await, + let mut roster = Vec::with_capacity(session_ids.len().saturating_add(1)); + let mut seen = HashSet::with_capacity(session_ids.len().saturating_add(1)); + roster.push(root_session_id.to_string()); + seen.insert(root_session_id.to_string()); + roster.extend( + session_ids + .iter() + .filter(|session_id| seen.insert((*session_id).clone())) + .cloned(), + ); + for session_id in roster { + let store = open_session(store_root, &session_id).await?; + let loaded = load_session(&*store, &session_id).await?; + let Some(meta) = loaded.meta else { + return Err(LoadSessionError::SessionNotFound(session_id)); }; - let chronological = build_chronological(graph); candidates.push(CandidateLoad { - db_path: path, + db_path: store_root.join("durable-core.db"), meta, - chronological, - context_window_tokens, + chronological: loaded.chronological, + model_id: loaded.model_id, + context_window_tokens: loaded.context_window_tokens, }); } - // Locate the root by matching the root_db path. let root_idx = candidates .iter() - .position(|c| same_file(&c.db_path, root_db)) - .ok_or_else(|| anyhow!("root db {} not found in sessions dir", root_db.display()))?; + .position(|candidate| candidate.meta.session_id == root_session_id) + .ok_or_else(|| LoadSessionError::SessionNotFound(root_session_id.to_string()))?; let root_id = candidates[root_idx].meta.session_id.clone(); // Walk the parent chain to keep only sessions reachable from root. @@ -183,10 +176,13 @@ pub async fn load_tree_from_paths(root_db: &Path, trace_path: &Path) -> Result Result {} - lash_core::SessionRelation::Child { + SessionRelation::Root => {} + SessionRelation::Child { parent_session_id, caused_by, } => { @@ -225,6 +221,7 @@ pub async fn load_tree_from_paths(root_db: &Path, trace_path: &Path) -> Result {} } } @@ -238,7 +235,7 @@ pub async fn load_tree_from_paths(root_db: &Path, trace_path: &Path) -> Result Result Result Result Vec { - let state = lash_core::SessionSnapshot { - session_graph: graph, - ..lash_core::SessionSnapshot::default() - }; - state.read_view().chronological_projection().into_entries() -} - fn tool_call_id_from_cause(caused_by: &Option) -> Option { match caused_by { Some(lash_core::CausalRef::ToolCall { call_id, .. }) => Some(call_id.clone()), _ => None, } } - -fn same_file(a: &Path, b: &Path) -> bool { - fs::canonicalize(a) - .ok() - .zip(fs::canonicalize(b).ok()) - .map(|(a, b)| a == b) - .unwrap_or(false) -} From e4e579a9a9aac3e49d6e8d319534ea0bfefaa9c4 Mon Sep 17 00:00:00 2001 From: Samuel Galanakis Date: Sun, 23 Aug 2026 18:39:26 +0200 Subject: [PATCH 07/25] Cut CLI runtime over to Lash facade Replace alpha-era persistence, provider factories, event handling, and plugin plumbing with facade-owned composition and typed durable inputs. Pin session policy and RLM dialects while keeping provider config and the session roster host-owned. Release-Notes: Move the CLI to the Lash facade with durable unified storage and selectable RLM dialects. --- Cargo.lock | 3 + crates/lash-cli/Cargo.toml | 1 + crates/lash-cli/src/activity/mod.rs | 15 +- .../src/activity/projectors/update_plan.rs | 13 +- crates/lash-cli/src/app/input.rs | 16 +- crates/lash-cli/src/app/live.rs | 65 +- crates/lash-cli/src/app/mod.rs | 78 +- crates/lash-cli/src/app/projection.rs | 101 +- crates/lash-cli/src/app/queues.rs | 35 +- crates/lash-cli/src/app/runtime_events.rs | 166 +- crates/lash-cli/src/app/tests.rs | 158 -- crates/lash-cli/src/app/tests/projection.rs | 1211 --------------- crates/lash-cli/src/app/tests/render.rs | 1357 ----------------- .../lash-cli/src/app/tests/runtime_state.rs | 1353 ---------------- crates/lash-cli/src/app/view.rs | 9 - crates/lash-cli/src/autonomous.rs | 107 +- crates/lash-cli/src/capability_catalog.rs | 1 + crates/lash-cli/src/config.rs | 334 +++- crates/lash-cli/src/event.rs | 5 +- .../src/examples/mcp_discovery/mod.rs | 370 ----- .../src/examples/mcp_discovery/plugin.rs | 172 --- .../src/examples/mcp_discovery/service.rs | 499 ------ crates/lash-cli/src/examples/mod.rs | 4 - crates/lash-cli/src/execution_settings.rs | 246 +-- crates/lash-cli/src/fork.rs | 383 +---- crates/lash-cli/src/host_docs.rs | 80 +- crates/lash-cli/src/host_policy.rs | 51 + crates/lash-cli/src/info.rs | 49 +- crates/lash-cli/src/input_items.rs | 28 +- .../lash-cli/src/interactive/commands/mod.rs | 79 +- .../src/interactive/commands/provider.rs | 31 +- .../src/interactive/commands/session.rs | 89 +- crates/lash-cli/src/interactive/helpers.rs | 18 +- .../src/interactive/input_handling/mod.rs | 6 - .../src/interactive/input_handling/modals.rs | 21 +- .../interactive/input_handling/shortcuts.rs | 97 +- .../src/interactive/input_handling/turns.rs | 4 +- crates/lash-cli/src/interactive/mod.rs | 72 +- crates/lash-cli/src/interactive/runtime.rs | 92 +- crates/lash-cli/src/interactive/tests.rs | 607 -------- crates/lash-cli/src/lib.rs | 1 + crates/lash-cli/src/main.rs | 94 +- .../{examples => }/mcp_discovery/catalog.rs | 3 +- .../{examples => }/mcp_discovery/common.rs | 1 + .../mcp_discovery/definitions.rs | 29 +- crates/lash-cli/src/mcp_discovery/mod.rs | 73 + crates/lash-cli/src/mcp_discovery/plugin.rs | 74 + .../{examples => }/mcp_discovery/ranking.rs | 341 +---- .../{examples => }/mcp_discovery/rerank.rs | 125 +- .../{examples => }/mcp_discovery/resolver.rs | 52 +- .../mcp_discovery/schema_index.rs | 1 + crates/lash-cli/src/mcp_discovery/service.rs | 188 +++ crates/lash-cli/src/model_catalog.rs | 72 +- crates/lash-cli/src/model_selection.rs | 7 +- crates/lash-cli/src/overlay.rs | 17 +- crates/lash-cli/src/paths.rs | 36 +- crates/lash-cli/src/persistence.rs | 20 - crates/lash-cli/src/plan_plugin.rs | 138 ++ crates/lash-cli/src/plugin_surface.rs | 145 +- crates/lash-cli/src/prompt_context_plugin.rs | 123 +- crates/lash-cli/src/prompt_model.rs | 1 + crates/lash-cli/src/prompt_tool.rs | 70 +- .../src/render/compositor/overlays.rs | 28 +- .../lash-cli/src/render/compositor/status.rs | 9 + .../lash-cli/src/render/compositor/tests.rs | 29 +- crates/lash-cli/src/render/sections/docks.rs | 28 +- crates/lash-cli/src/render/tests.rs | 57 +- crates/lash-cli/src/resume.rs | 500 +----- crates/lash-cli/src/session_log.rs | 1164 +++----------- crates/lash-cli/src/startup/mod.rs | 141 +- crates/lash-cli/src/startup/onboarding.rs | 332 ++-- crates/lash-cli/src/startup/plugins.rs | 113 +- crates/lash-cli/src/startup/preflight.rs | 41 +- crates/lash-cli/src/startup/provider.rs | 37 +- crates/lash-cli/src/startup/session.rs | 625 +++----- crates/lash-cli/src/test_support.rs | 432 ------ crates/lash-cli/src/tree.rs | 116 +- crates/lash-cli/src/turn_runner.rs | 186 +-- crates/lash-cli/src/ui_effects.rs | 13 +- crates/lash-cli/src/ui_perf/export_cases.rs | 1 + .../lash-cli/src/ui_perf/projection_cases.rs | 51 +- .../lash-cli/src/ui_perf/surface_fixture.rs | 62 +- crates/lash-cli/src/ui_trace.rs | 12 +- 83 files changed, 2767 insertions(+), 10847 deletions(-) delete mode 100644 crates/lash-cli/src/app/tests.rs delete mode 100644 crates/lash-cli/src/app/tests/projection.rs delete mode 100644 crates/lash-cli/src/app/tests/render.rs delete mode 100644 crates/lash-cli/src/app/tests/runtime_state.rs delete mode 100644 crates/lash-cli/src/examples/mcp_discovery/mod.rs delete mode 100644 crates/lash-cli/src/examples/mcp_discovery/plugin.rs delete mode 100644 crates/lash-cli/src/examples/mcp_discovery/service.rs delete mode 100644 crates/lash-cli/src/examples/mod.rs create mode 100644 crates/lash-cli/src/host_policy.rs delete mode 100644 crates/lash-cli/src/interactive/tests.rs rename crates/lash-cli/src/{examples => }/mcp_discovery/catalog.rs (97%) rename crates/lash-cli/src/{examples => }/mcp_discovery/common.rs (99%) rename crates/lash-cli/src/{examples => }/mcp_discovery/definitions.rs (75%) create mode 100644 crates/lash-cli/src/mcp_discovery/mod.rs create mode 100644 crates/lash-cli/src/mcp_discovery/plugin.rs rename crates/lash-cli/src/{examples => }/mcp_discovery/ranking.rs (61%) rename crates/lash-cli/src/{examples => }/mcp_discovery/rerank.rs (68%) rename crates/lash-cli/src/{examples => }/mcp_discovery/resolver.rs (62%) rename crates/lash-cli/src/{examples => }/mcp_discovery/schema_index.rs (94%) create mode 100644 crates/lash-cli/src/mcp_discovery/service.rs delete mode 100644 crates/lash-cli/src/persistence.rs create mode 100644 crates/lash-cli/src/plan_plugin.rs diff --git a/Cargo.lock b/Cargo.lock index fb50f86..7982ccc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2108,6 +2108,7 @@ dependencies = [ "lash-standard-plugins", "lash-subagents", "lash-tool-support", + "lash-trace", "lash-tui", "lash-tui-extensions", "lashlang", @@ -2191,9 +2192,11 @@ dependencies = [ "lash-core", "lash-protocol-rlm", "lash-rlm-types", + "lash-runtime", "lash-sqlite-store", "lash-trace", "pulldown-cmark", + "rusqlite", "serde", "serde_json", "tempfile", diff --git a/crates/lash-cli/Cargo.toml b/crates/lash-cli/Cargo.toml index 4e0281c..ee7aa3c 100644 --- a/crates/lash-cli/Cargo.toml +++ b/crates/lash-cli/Cargo.toml @@ -79,6 +79,7 @@ model2vec-rs = { version = "0.1.4", optional = true } [dev-dependencies] lash-debug-cli-harness = { path = "../lash-debug-cli-harness" } lash-core = { workspace = true, features = ["testing"] } +lash-trace = { workspace = true } lashlang = { workspace = true } tempfile = { workspace = true } diff --git a/crates/lash-cli/src/activity/mod.rs b/crates/lash-cli/src/activity/mod.rs index b9e950a..c06a20d 100644 --- a/crates/lash-cli/src/activity/mod.rs +++ b/crates/lash-cli/src/activity/mod.rs @@ -359,10 +359,10 @@ impl ActivityState { // nothing and the children render from those real events. return Vec::new(); } - let status = match output.status() { - lash_core::ToolCallStatus::Success => ActivityStatus::Completed, - lash_core::ToolCallStatus::Failure => ActivityStatus::Failed, - lash_core::ToolCallStatus::Cancelled => ActivityStatus::Cancelled, + let status = match &output.outcome { + lash_core::ToolCallOutcome::Success(_) => ActivityStatus::Completed, + lash_core::ToolCallOutcome::Failure(_) => ActivityStatus::Failed, + lash_core::ToolCallOutcome::Cancelled(_) => ActivityStatus::Cancelled, }; let mut ctx = ProjectCtx { name, @@ -427,9 +427,10 @@ impl ActivityState { let output = if success { lash_core::ToolCallOutput::success(result) } else { - lash_core::ToolResult::err(result) - .into_done_output() - .expect("static failure output") + lash_core::ToolCallOutput::failure(lash_core::ToolFailure::invalid_request( + "test_failure", + result.to_string(), + )) }; self.project_tool_output(name, args, output, duration_ms) } diff --git a/crates/lash-cli/src/activity/projectors/update_plan.rs b/crates/lash-cli/src/activity/projectors/update_plan.rs index a0438e7..9fda757 100644 --- a/crates/lash-cli/src/activity/projectors/update_plan.rs +++ b/crates/lash-cli/src/activity/projectors/update_plan.rs @@ -115,7 +115,18 @@ fn update_plan_failure_lines(args: &Value, result: &Value) -> Vec { .map(str::trim) .filter(|value| !value.is_empty()) { - lines.push(inline_text(message)); + let message = + serde_json::from_str::(message).unwrap_or_else(|_| message.to_string()); + lines.push(inline_text(&message)); + } else if let Some(message) = result + .get("message") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let message = + serde_json::from_str::(message).unwrap_or_else(|_| message.to_string()); + lines.push(inline_text(&message)); } lines.extend(update_plan_detail_lines(args)); lines diff --git a/crates/lash-cli/src/app/input.rs b/crates/lash-cli/src/app/input.rs index 57a1fb7..2d17b12 100644 --- a/crates/lash-cli/src/app/input.rs +++ b/crates/lash-cli/src/app/input.rs @@ -519,9 +519,21 @@ impl App { } } + #[cfg(test)] pub fn show_session_picker(&mut self, items: Vec) { + self.show_session_picker_with_incompatible(items, 0); + } + + pub fn show_session_picker_with_incompatible( + &mut self, + items: Vec, + incompatible_session_count: usize, + ) { self.overlay = Some(OverlayState::SessionPicker( - crate::overlay::SessionPickerState::new(items), + crate::overlay::SessionPickerState::with_incompatible_sessions( + items, + incompatible_session_count, + ), )); self.dirty = true; } @@ -551,7 +563,7 @@ impl App { self.overlay = Some(OverlayState::SkillPicker(PickerState::new(items))); } - pub fn show_tree(&mut self, roots: Vec) { + pub fn show_tree(&mut self, roots: Vec) { self.overlay = Some(OverlayState::Tree(crate::overlay::TreeState::new(roots))); } diff --git a/crates/lash-cli/src/app/live.rs b/crates/lash-cli/src/app/live.rs index a21b0a3..64cc580 100644 --- a/crates/lash-cli/src/app/live.rs +++ b/crates/lash-cli/src/app/live.rs @@ -1,11 +1,6 @@ use super::*; use crate::assistant_text::{push_assistant_reasoning_block, push_assistant_text_block}; -#[cfg(test)] -const STREAMING_OUTPUT_MAX_LINES: usize = 48; -#[cfg(test)] -const STREAMING_OUTPUT_LINE_CHAR_LIMIT: usize = 240; - pub struct LiveTurnState { pub run_state: CliRunState, pub status_detail: Option, @@ -65,52 +60,6 @@ impl LiveToolOutput { pub(crate) fn height(&self) -> usize { usize::from(self.hidden > 0) + self.lines.len() + usize::from(!self.partial.is_empty()) } - - #[cfg(test)] - pub(super) fn push_text(&mut self, text: &str, line_char_limit: usize, max_lines: usize) { - let sanitized = strip_ansi_escape_sequences(text); - let mut chars = sanitized.chars().peekable(); - while let Some(ch) = chars.next() { - match ch { - '\r' if matches!(chars.peek(), Some('\n')) => { - chars.next(); - let completed = std::mem::take(&mut self.partial); - self.push_line(completed, line_char_limit, max_lines); - } - '\r' => { - self.partial.clear(); - } - '\n' => { - let completed = std::mem::take(&mut self.partial); - self.push_line(completed, line_char_limit, max_lines); - } - '\t' => { - if !self.partial.chars().last().is_some_and(char::is_whitespace) { - self.partial.push(' '); - } - } - '\u{8}' | '\u{7f}' => { - self.partial.pop(); - } - control if control.is_control() => {} - _ => self.partial.push(ch), - } - - if self.partial.chars().count() > line_char_limit { - self.partial = smart_truncate_preview_line(&self.partial, line_char_limit); - } - } - } - - #[cfg(test)] - fn push_line(&mut self, line: String, line_char_limit: usize, max_lines: usize) { - if self.lines.len() == max_lines { - self.lines.remove(0); - self.hidden += 1; - } - self.lines - .push(smart_truncate_preview_line(&line, line_char_limit)); - } } pub(super) fn live_tool_output_title(name: &str, args: &serde_json::Value) -> String { @@ -205,7 +154,6 @@ impl App { pub fn stop_turn(&mut self) { self.invalidate_live_reasoning_tail(); self.foreground_turn_active = false; - self.manual_interrupt_requested = false; self.pending_retry_status = None; self.active_ui_turn_ordinal = None; self.next_lashlang_block_ordinal = 0; @@ -298,6 +246,10 @@ impl App { self.manual_interrupt_requested = true; } + pub fn clear_manual_interrupt_requested(&mut self) { + self.manual_interrupt_requested = false; + } + pub(super) fn mark_first_token_arrived(&mut self) { if self .live @@ -489,13 +441,4 @@ impl App { self.commit_live_reasoning_block(); self.commit_live_assistant_block(); } - - #[cfg(test)] - pub(super) fn push_test_tool_output(&mut self, text: &str) { - self.live.tool_output.push_text( - text, - STREAMING_OUTPUT_LINE_CHAR_LIMIT, - STREAMING_OUTPUT_MAX_LINES, - ); - } } diff --git a/crates/lash-cli/src/app/mod.rs b/crates/lash-cli/src/app/mod.rs index 4aa6de9..42d2252 100644 --- a/crates/lash-cli/src/app/mod.rs +++ b/crates/lash-cli/src/app/mod.rs @@ -9,10 +9,11 @@ mod view; use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::{Arc, Mutex}; +use lash::process::{ProcessHandleView, ProcessStatus}; use lash_core::runtime::PendingTurnInput; use lash_core::{ - Message, MessageRole, PartKind, PluginMessage, ProcessHandleSummary, ProcessLifecycleStatus, - PromptUsage, SessionProcessEventKind, TokenUsage, ToolCallRecord, TurnActivityId, + Message, MessageRole, PartKind, PluginMessage, PromptUsage, SessionProcessEventKind, + TokenUsage, ToolCallRecord, TurnActivityId, }; use lash_tui::{Line, Rect}; use lash_tui_extensions::TuiExtensions; @@ -34,8 +35,6 @@ pub(crate) use self::projection::{ UiActivityJournal, UiActivityRecord, UiTimeline, UiTimelineItem, preview_text_lines, smart_truncate_preview_line, timeline_from_read_view, }; -#[cfg(test)] -pub(crate) use self::projection::{interrupted_blocks_from_read_view, strip_ansi_escape_sequences}; fn user_turn_start_indices(blocks: &[UiTimelineItem]) -> Vec { blocks @@ -114,7 +113,7 @@ pub struct ProcessView { pub kind: String, pub label: String, pub definition: Option, - pub status: ProcessLifecycleStatus, + pub status: ProcessStatus, pub first_seen: std::time::Instant, pub status_duration: Option, pub transient_until: Option, @@ -122,19 +121,15 @@ pub struct ProcessView { impl ProcessView { fn from_summary( - summary: ProcessHandleSummary, + summary: ProcessHandleView, first_seen: std::time::Instant, status_duration: Option, transient_until: Option, ) -> Self { - let kind = summary - .descriptor - .kind - .unwrap_or_else(|| "process".to_string()); + let kind = summary.kind; let label = summary - .descriptor .label - .unwrap_or_else(|| summary.process_id.clone()); + .unwrap_or_else(|| summary.process_id.to_string()); let definition = summary.definition.map(|definition| { definition .get("process_name") @@ -143,7 +138,7 @@ impl ProcessView { .unwrap_or_else(|| definition.to_string()) }); Self { - process_id: summary.process_id, + process_id: summary.process_id.to_string(), kind, label, definition, @@ -437,9 +432,6 @@ pub(crate) const SPLASH_CONTENT_HEIGHT: usize = 8; pub(crate) const SPLASH_SCROLLBACK_HEIGHT: usize = SPLASH_CONTENT_HEIGHT + 2; pub(super) const FOLLOW_OUTPUT_CONTEXT_LINES: usize = 2; -#[cfg(test)] -mod tests; - /// Mouse text selection state for selectable output surfaces. /// /// Coordinates are in **content space**: (column, virtual_row) where the @@ -495,17 +487,9 @@ pub struct RenderCache { /// into a flat field bag on [`App`]. #[derive(Default)] pub struct UsageState { - /// Cumulative token usage for the current session: parent's own LLM - /// tokens plus the latest cumulative reported by each child session. - /// Recomputed on every [`lash::TurnEvent::Usage`] and - /// [`lash::TurnEvent::ChildUsage`]. + /// Cumulative token usage for the current session, refreshed from Lash's + /// canonical session usage report when a turn completes. pub token_usage: TokenUsage, - /// Parent's own LLM cumulative, kept separately so we can recompute - /// `token_usage` when child sessions update. - pub parent_session_cumulative: TokenUsage, - /// Latest cumulative reported per child session (subagents, compaction, - /// observers). Keyed by child session id. - pub child_session_cumulatives: HashMap, /// Context window size for the current model (from models.dev). pub context_window: Option, /// Latest completed model usage for context accounting. @@ -573,6 +557,9 @@ pub struct Queues { /// admission owns ordering and lifecycle; this cache only preserves draft /// text, images, and paste placeholders the runtime cannot reconstruct. pub draft_presentations: HashMap, + /// Active-turn drafts retained until the turn settles so a manual + /// interruption can preserve operator input at the next-turn boundary. + pub active_turn_drafts: Vec, /// Latest durable pending-turn-input snapshot loaded from `LashSession`. pub pending_turn_input_snapshot: Vec, /// Input ids that have already been claimed for dispatch locally but may @@ -625,6 +612,8 @@ pub struct App { pub model_variant: Option, /// Display-facing execution mode for the active runtime. pub execution_mode_label: String, + /// Pinned RLM dialect for the active session, when applicable. + pub rlm_dialect_label: Option, /// Unique session name (e.g. "alpine-canyon"). pub session_name: String, /// Live session id (UUID) for the active runtime. Updated on resume @@ -764,6 +753,7 @@ impl App { let local_system_messages = self.local_system_messages(); self.stop_turn(); + self.clear_active_turn_drafts(); let ui_state = UiProjectionState::from_app(self); let projected_timeline = timeline_from_read_view(read_view, &ui_state); let projected_turn_starts = user_turn_start_indices(projected_timeline.items()); @@ -802,6 +792,7 @@ impl App { ) { let local_system_messages = self.local_system_messages(); self.stop_turn(); + self.clear_active_turn_drafts(); let mut projected_timeline = timeline_from_read_view(read_view, ui_state); Self::append_missing_system_messages(&mut projected_timeline, local_system_messages); projected_timeline.push_system_message_if_new(status_message.into()); @@ -899,6 +890,7 @@ impl App { usage: UsageState::default(), model_variant: Default::default(), execution_mode_label: "standard".to_string(), + rlm_dialect_label: None, session_name, session_id, repo_status: std::env::current_dir() @@ -1015,27 +1007,6 @@ impl App { self.editor.take_large_pastes() } - /// Recompute the session-wide cumulative token usage from the parent's - /// cumulative plus the latest cumulative reported by each child session. - fn recompute_session_token_usage(&mut self) { - let mut total = self.usage.parent_session_cumulative.clone(); - for child in self.usage.child_session_cumulatives.values() { - total.add(child); - } - self.usage.token_usage = total; - } - - /// Remove the empty-state splash once real conversation content is present. - #[cfg(test)] - pub fn dismiss_splash(&mut self) { - if !matches!(self.timeline.first(), Some(UiTimelineItem::Splash)) { - return; - } - self.timeline - .retain(|block| !matches!(block, UiTimelineItem::Splash)); - self.invalidate_height_cache(); - } - pub fn set_ui_extensions(&mut self, ui_extensions: Arc) { self.ui_extensions = ui_extensions; self.ui_extensions.mount_surface( @@ -1067,7 +1038,7 @@ impl App { self.dirty = true; } - pub fn update_processes(&mut self, tasks: Vec) { + pub fn update_processes(&mut self, tasks: Vec) { let now = std::time::Instant::now(); let previous: HashMap = self .processes @@ -1079,8 +1050,8 @@ impl App { for task in tasks { let old_status = previous .get(&task.process_id) - .and_then(|item| item.status.terminal_state()); - let status = task.status.terminal_state(); + .and_then(|item| item.status.is_terminal().then_some(item.status)); + let status = task.status.is_terminal().then_some(task.status); let first_seen = previous .get(&task.process_id) .map(|item| item.first_seen) @@ -1136,7 +1107,7 @@ impl App { if !process_ids.contains(process.process_id.as_str()) || process.status.is_terminal() { continue; } - process.status = ProcessLifecycleStatus::Cancelled; + process.status = ProcessStatus::Cancelled; process .status_duration .get_or_insert_with(|| process.first_seen.elapsed()); @@ -1225,6 +1196,11 @@ impl App { crate::execution_settings::execution_mode_label(mode).to_string(); self.dirty = true; } + + pub fn set_rlm_dialect(&mut self, dialect: Option) { + self.rlm_dialect_label = dialect.map(|dialect| dialect.language_id().to_string()); + self.dirty = true; + } } pub use lash_export::transcript::format_tokens; diff --git a/crates/lash-cli/src/app/projection.rs b/crates/lash-cli/src/app/projection.rs index 86f8578..582a346 100644 --- a/crates/lash-cli/src/app/projection.rs +++ b/crates/lash-cli/src/app/projection.rs @@ -493,18 +493,9 @@ fn activity_journal_insert_position( Some(turn_end) } -#[cfg(test)] -pub(crate) fn interrupted_blocks_from_read_view( - read_view: &lash_core::SessionReadView, - ui_state: &UiProjectionState, - status_message: impl Into, +fn timeline_from_chronological( + projection: &lash::persistence::ChronologicalProjection, ) -> UiTimeline { - let mut timeline = timeline_from_read_view(read_view, ui_state); - timeline.push_system_message_if_new(status_message.into()); - timeline -} - -fn timeline_from_chronological(projection: &lash_core::ChronologicalProjection) -> UiTimeline { let mut timeline = UiTimeline::new(); let mut activity_state = ActivityState::default(); let tool_call_map = HashMap::new(); @@ -602,70 +593,6 @@ pub(crate) fn smart_truncate_preview_line(text: &str, max_chars: usize) -> Strin format!("{prefix}{marker}{suffix}") } -#[cfg(test)] -pub(crate) fn strip_ansi_escape_sequences(text: &str) -> String { - let bytes = text.as_bytes(); - let mut out = String::with_capacity(text.len()); - let mut idx = 0usize; - while idx < bytes.len() { - if bytes[idx] == 0x1b { - idx += 1; - if idx >= bytes.len() { - break; - } - match bytes[idx] { - b'[' => { - idx += 1; - while idx < bytes.len() { - let byte = bytes[idx]; - idx += 1; - if (0x40..=0x7e).contains(&byte) { - break; - } - } - } - b']' => { - idx += 1; - while idx < bytes.len() { - match bytes[idx] { - 0x07 => { - idx += 1; - break; - } - 0x1b if idx + 1 < bytes.len() && bytes[idx + 1] == b'\\' => { - idx += 2; - break; - } - _ => idx += 1, - } - } - } - b'P' | b'X' | b'^' | b'_' => { - idx += 1; - while idx < bytes.len() { - if bytes[idx] == 0x1b && idx + 1 < bytes.len() && bytes[idx + 1] == b'\\' { - idx += 2; - break; - } - idx += 1; - } - } - _ => { - idx += 1; - } - } - continue; - } - if let Some(ch) = text[idx..].chars().next() { - out.push(ch); - idx += ch.len_utf8(); - } else { - break; - } - } - out -} - fn append_transcript_items( timeline: &mut UiTimeline, message: &Message, @@ -678,9 +605,11 @@ fn append_transcript_items( } match message.role { MessageRole::User => { - if message.parts.iter().any(|part| { - part_is_rlm_exec_result(part) || matches!(part.kind, PartKind::ToolResult) - }) { + if message + .parts + .iter() + .any(|part| matches!(part.kind, PartKind::ToolResult)) + { if render_tool_results { for part in message.parts.iter() { append_tool_result_items(timeline, part, tool_calls, activity_state); @@ -714,7 +643,7 @@ fn append_transcript_items( continue; }; match part.kind { - PartKind::Text | PartKind::Prose | PartKind::Image => prose.push(text), + PartKind::Text | PartKind::Prose | PartKind::Attachment => prose.push(text), PartKind::ToolCall => { flush_assistant_prose_items(timeline, &mut prose); } @@ -763,24 +692,12 @@ fn is_internal_rlm_message(message: &Message) -> bool { } } -/// True when a legacy user-message part carries an RLM exec result. -/// Current RLM history uses trajectory events instead; these old -/// messages should stay off rather than render as fake tool calls. -fn part_is_rlm_exec_result(part: &lash_core::Part) -> bool { - matches!(part.kind, PartKind::Text) - && part.tool_call_id.is_some() - && part.tool_name.as_deref() == Some("execute_lashlang") -} - fn append_tool_result_items( timeline: &mut UiTimeline, part: &lash_core::Part, tool_calls: &HashMap<&str, ToolCallRecord>, activity_state: &mut ActivityState, ) { - if part_is_rlm_exec_result(part) { - return; - } if !matches!(part.kind, PartKind::ToolResult) { return; } @@ -847,7 +764,7 @@ pub(crate) fn rendered_message_text(message: &Message) -> String { fn rendered_part_text(kind: &PartKind, content: &str) -> Option { match kind { PartKind::Reasoning | PartKind::ToolCall | PartKind::ToolResult => None, - PartKind::Image => Some("[Image attached]".to_string()), + PartKind::Attachment => Some("[Attachment]".to_string()), _ => (!content.trim().is_empty()).then(|| content.to_string()), } } diff --git a/crates/lash-cli/src/app/queues.rs b/crates/lash-cli/src/app/queues.rs index 2fb5fc0..96b3221 100644 --- a/crates/lash-cli/src/app/queues.rs +++ b/crates/lash-cli/src/app/queues.rs @@ -34,7 +34,7 @@ pub(crate) fn turn_input_display_text(input: &lash_core::TurnInput) -> String { .iter() .filter_map(|item| match item { lash_core::InputItem::Text { text } => Some(text.as_str()), - lash_core::InputItem::ImageRef { .. } => None, + lash_core::InputItem::Attachment { .. } => None, }) .collect::>() .join("") @@ -48,13 +48,16 @@ pub(crate) fn prepared_turn_from_queued_input( .items .iter() .filter_map(|item| match item { - lash_core::InputItem::ImageRef { id } => input.image_blobs.get(id), + lash_core::InputItem::Attachment { + source: lash_core::AttachmentSource::Inline { bytes, .. }, + } => Some(bytes), + lash_core::InputItem::Attachment { .. } => None, lash_core::InputItem::Text { .. } => None, }) .enumerate() .map(|(idx, png_bytes)| PendingImage { id: idx + 1, - png_bytes: png_bytes.clone(), + png_bytes: png_bytes.to_vec(), }) .collect::>(); if text.trim().is_empty() && images.is_empty() { @@ -109,6 +112,19 @@ impl App { .insert(turn.draft_id.clone(), turn); } + pub fn cache_active_turn_draft(&mut self, turn: PreparedTurn) { + self.cache_draft_presentation(turn.clone()); + self.queues.active_turn_drafts.push(turn); + } + + pub fn take_active_turn_drafts(&mut self) -> Vec { + std::mem::take(&mut self.queues.active_turn_drafts) + } + + pub fn clear_active_turn_drafts(&mut self) { + self.queues.active_turn_drafts.clear(); + } + pub(super) fn take_draft_presentation_by_id(&mut self, draft_id: &str) -> Option { self.queues.take_by_draft_id(draft_id) } @@ -120,19 +136,6 @@ impl App { self.queues.take_matching_content(content) } - pub(super) fn take_draft_presentation_for_input( - &mut self, - id: Option<&str>, - content: &str, - ) -> Option { - if let Some(id) = id - && let Some(turn) = self.take_draft_presentation_by_id(id) - { - return Some(turn); - } - self.take_matching_draft_presentation(content) - } - pub fn set_pending_turn_input_snapshot(&mut self, inputs: Vec) { let visible_input_ids = pending_turn_input_ids(&inputs); let suppressed_before = self.queues.suppressed_preview_input_ids.len(); diff --git a/crates/lash-cli/src/app/runtime_events.rs b/crates/lash-cli/src/app/runtime_events.rs index 4983739..4afe5c5 100644 --- a/crates/lash-cli/src/app/runtime_events.rs +++ b/crates/lash-cli/src/app/runtime_events.rs @@ -1,8 +1,6 @@ use super::*; use crate::assistant_text::push_assistant_text_block; -#[cfg(test)] -use lash_core::SessionStreamEvent; -use lash_core::{AcceptedInjectedTurnInput, PluginRuntimeEvent, TurnActivity, TurnEvent}; +use lash_core::{PluginRuntimeEvent, TurnActivity, TurnEvent, TurnInputApplication}; fn runtime_status_from_plugin_event( event: &PluginRuntimeEvent, @@ -138,23 +136,26 @@ impl App { } } - fn accept_injected_turn_input(&mut self, inputs: &[AcceptedInjectedTurnInput]) { + fn accept_injected_turn_input(&mut self, applications: &[TurnInputApplication]) { self.finalize_live_markdown(); let mut accepted_user_message = false; - for input in inputs { - let message = &input.message; - if !matches!(message.role, MessageRole::User) { - continue; - } - accepted_user_message = true; - if let Some(turn) = - self.take_draft_presentation_for_input(input.id.as_deref(), &message.content) + let pending = self.pending_turn_input_snapshot().to_vec(); + for application in applications { + if let Some(input) = pending + .iter() + .find(|input| input.input_id == application.input_id) + && let Some(turn) = self.take_prepared_turn_for_pending_input(input) { + accepted_user_message = true; self.push_prepared_user_input(&turn); - } else { - self.push_user_input_history_text(message.content.clone()); } } + self.remove_pending_turn_inputs( + &applications + .iter() + .map(|application| application.input_id.clone()) + .collect::>(), + ); if accepted_user_message { self.mark_live_turn_user_input_visible(); self.keep_latest_user_block_visible(); @@ -234,7 +235,7 @@ impl App { self.live.model_output_chunks.push(ModelOutputChunk { correlation_id: activity.correlation_id, lane: ModelOutputLane::Reasoning, - text: text.clone(), + text: text.to_string(), }); self.append_model_output(ModelOutputLane::Reasoning, &text); } @@ -242,7 +243,7 @@ impl App { self.live.model_output_chunks.push(ModelOutputChunk { correlation_id: activity.correlation_id, lane: ModelOutputLane::Assistant, - text: text.clone(), + text: text.to_string(), }); self.append_model_output(ModelOutputLane::Assistant, &text); } @@ -438,23 +439,15 @@ impl App { || usage.reasoning_output_tokens > 0 || usage.cache_read_input_tokens > 0; self.usage.last_response_usage = usage.clone(); - self.usage.parent_session_cumulative = cumulative; - self.recompute_session_token_usage(); + self.usage.token_usage = cumulative; if should_clear_live_estimate { self.usage.live_output_chars_estimate = 0; self.usage.live_output_tokens_estimate = 0; } } - TurnEvent::ChildUsage { - session_id, - cumulative, - .. - } => { - self.usage - .child_session_cumulatives - .insert(session_id, cumulative); - self.recompute_session_token_usage(); - } + // Lash owns child aggregation. The completed `TurnReport` and + // `LashSession::usage_report` refresh the UI from canonical data. + TurnEvent::ChildUsage { .. } => {} TurnEvent::PluginRuntime { plugin_id, event } => { if let Some((status, detail, duration)) = runtime_status_from_plugin_event(&event) { self.set_transient_status(status, detail, duration); @@ -485,8 +478,8 @@ impl App { self.dirty = true; } } - TurnEvent::QueuedInputAccepted { inputs, .. } => { - self.accept_injected_turn_input(&inputs); + TurnEvent::QueuedInputAccepted { applications } => { + self.accept_injected_turn_input(&applications); } TurnEvent::QueuedMessagesCommitted { messages, .. } => { self.commit_injected_messages(&messages); @@ -527,118 +520,7 @@ impl App { } } TurnEvent::ToolValue { .. } => {} + TurnEvent::ModelCallRecorded { .. } | TurnEvent::ToolIntentOutcome { .. } => {} } } - - #[cfg(test)] - pub fn handle_session_event(&mut self, event: SessionStreamEvent) { - if matches!(event, SessionStreamEvent::Done) { - self.finalize_live_markdown(); - self.stop_turn(); - self.scroll_to_bottom(); - } else if let SessionStreamEvent::Message { text, kind } = &event - && kind == "tool_output" - { - let current_status = self.live.turn.as_ref().map(|turn| turn.run_state); - let stream_active = self.turn_active() - || current_status.is_some_and(|status| status == CliRunState::RunningTool); - if stream_active { - self.push_test_tool_output(text); - self.invalidate_live_tool_output_cache(); - self.mark_visible_output(); - self.scroll_to_bottom(); - } - } else if let Some(activity) = test_session_event_to_turn_activity(event) { - self.handle_turn_activity(activity); - } - } -} - -#[cfg(test)] -fn test_session_event_to_turn_activity(event: SessionStreamEvent) -> Option { - let turn_event = match event { - SessionStreamEvent::LlmRequest { - protocol_iteration, .. - } => TurnEvent::ModelRequestStarted { protocol_iteration }, - SessionStreamEvent::TextDelta { content } => { - TurnEvent::AssistantProseDelta { text: content } - } - SessionStreamEvent::ReasoningDelta { content } => { - TurnEvent::ReasoningDelta { text: content } - } - SessionStreamEvent::ToolCallStart { - call_id, - name, - args, - } => TurnEvent::ToolCallStarted { - call_id, - name, - args, - graph_key: None, - parent_call_id: None, - }, - SessionStreamEvent::ToolCall { - call_id, - name, - args, - output, - duration_ms, - } => TurnEvent::ToolCallCompleted { - call_id, - name, - args, - output, - duration_ms, - graph_key: None, - parent_call_id: None, - }, - SessionStreamEvent::Message { text, kind } if kind == "lashlang_code" => { - TurnEvent::CodeBlockStarted { - language: "lashlang".to_string(), - code: text, - graph_key: None, - } - } - SessionStreamEvent::TokenUsage { - protocol_iteration, - usage, - cumulative, - } => TurnEvent::Usage { - protocol_iteration, - usage, - cumulative, - }, - SessionStreamEvent::RetryStatus { - wait_seconds, - attempt, - max_attempts, - reason, - .. - } => TurnEvent::RetryStatus { - wait_seconds, - attempt, - max_attempts, - reason, - }, - SessionStreamEvent::Error { message, .. } => TurnEvent::Error { message }, - SessionStreamEvent::PluginEvent { plugin_id, event } => { - TurnEvent::PluginRuntime { plugin_id, event } - } - SessionStreamEvent::InjectedTurnInputAccepted { inputs, checkpoint } => { - TurnEvent::QueuedInputAccepted { checkpoint, inputs } - } - SessionStreamEvent::InjectedMessagesCommitted { - messages, - checkpoint, - } => TurnEvent::QueuedMessagesCommitted { - messages, - checkpoint, - }, - SessionStreamEvent::Done - | SessionStreamEvent::ChildTokenUsage { .. } - | SessionStreamEvent::TurnOutcome { .. } - | SessionStreamEvent::LlmResponse { .. } - | SessionStreamEvent::Message { .. } => return None, - }; - Some(TurnActivity::independent(turn_event)) } diff --git a/crates/lash-cli/src/app/tests.rs b/crates/lash-cli/src/app/tests.rs deleted file mode 100644 index e7016ac..0000000 --- a/crates/lash-cli/src/app/tests.rs +++ /dev/null @@ -1,158 +0,0 @@ -use super::*; -use crate::editor::LARGE_PASTE_CHAR_THRESHOLD; -use async_trait::async_trait; -use lash_core::{Part, PruneState, SessionStreamEvent, TurnActivity, TurnEvent}; -use lash_tui_extensions::{ - SlashCommandSpec, TuiExtension, TuiExtensionContext, TuiExtensions, TuiHostEffect, -}; -use std::sync::Arc; - -fn text_message(id: &str, role: MessageRole, content: &str) -> Message { - Message { - id: id.to_string(), - role, - parts: vec![part(&format!("{id}.p0"), PartKind::Text, content)].into(), - origin: None, - } -} - -fn plugin_text_message(id: &str, role: MessageRole, plugin_id: &str, content: &str) -> Message { - Message { - id: id.to_string(), - role, - parts: vec![part(&format!("{id}.p0"), PartKind::Text, content)].into(), - origin: Some(lash_core::MessageOrigin::Plugin { - plugin_id: plugin_id.to_string(), - transient: false, - }), - } -} - -fn assistant_reasoning_text_message(id: &str, reasoning: &str, content: &str) -> Message { - Message { - id: id.to_string(), - role: MessageRole::Assistant, - parts: vec![ - part(&format!("{id}.r"), PartKind::Reasoning, reasoning), - part(&format!("{id}.t"), PartKind::Text, content), - ] - .into(), - origin: None, - } -} - -fn part(id: &str, kind: PartKind, content: &str) -> Part { - Part { - id: id.to_string(), - kind, - content: content.to_string(), - attachment: None, - tool_call_id: None, - tool_name: None, - tool_replay: None, - prune_state: PruneState::Intact, - reasoning_meta: None, - response_meta: None, - } -} - -fn conversation_event(message: Message) -> lash_core::SessionHistoryRecord { - lash_core::SessionHistoryRecord::Conversation(lash_core::ConversationRecord::from_message( - message, - )) -} - -fn events_from_messages(messages: &[Message]) -> Vec { - messages.iter().cloned().map(conversation_event).collect() -} - -fn test_read_view( - events: &[lash_core::SessionHistoryRecord], - messages: &[Message], - _tool_calls: &[ToolCallRecord], -) -> lash_core::SessionReadView { - let mut graph = lash_core::SessionGraph::default(); - for event in events { - match event { - lash_core::SessionHistoryRecord::Conversation(record) => { - graph.append_message(record.to_message()); - } - lash_core::SessionHistoryRecord::Protocol(event) => { - graph.append_protocol_event(event.clone()); - } - } - } - let event_message_ids = events - .iter() - .filter_map(|event| match event { - lash_core::SessionHistoryRecord::Conversation(record) => Some(record.id.as_str()), - _ => None, - }) - .collect::>(); - let missing_messages = messages - .iter() - .filter(|message| !event_message_ids.contains(message.id.as_str())) - .cloned() - .collect::>(); - graph.append_active_read_delta(&missing_messages); - let state = lash_core::SessionSnapshot { - session_graph: graph, - ..lash_core::SessionSnapshot::default() - }; - lash_core::SessionReadView::from_snapshot(&state) -} - -fn timeline_items_from_test_read_view( - events: &[lash_core::SessionHistoryRecord], - messages: &[Message], - tool_calls: &[ToolCallRecord], - ui_state: &UiProjectionState, -) -> Vec { - let read_view = test_read_view(events, messages, tool_calls); - timeline_from_read_view(&read_view, ui_state) - .items() - .to_vec() -} - -fn timeline_from_test_read_view( - events: &[lash_core::SessionHistoryRecord], - messages: &[Message], - tool_calls: &[ToolCallRecord], - ui_state: &UiProjectionState, -) -> UiTimeline { - let read_view = test_read_view(events, messages, tool_calls); - timeline_from_read_view(&read_view, ui_state) -} - -fn interrupted_blocks_from_test_read_view( - events: &[lash_core::SessionHistoryRecord], - messages: &[Message], - tool_calls: &[ToolCallRecord], - ui_state: &UiProjectionState, - status_message: impl Into, -) -> Vec { - let read_view = test_read_view(events, messages, tool_calls); - interrupted_blocks_from_read_view(&read_view, ui_state, status_message) - .items() - .to_vec() -} - -fn other_variant_name(block: &UiTimelineItem) -> &'static str { - match block { - UiTimelineItem::TurnStart(_) => "TurnStart", - UiTimelineItem::UserInput(_) => "UserInput", - UiTimelineItem::AssistantText(_) => "AssistantText", - UiTimelineItem::AssistantReasoning(_) => "AssistantReasoning", - UiTimelineItem::Activity(_) => "Activity", - UiTimelineItem::ShellOutput { .. } => "ShellOutput", - UiTimelineItem::Error(_) => "Error", - UiTimelineItem::SystemMessage(_) => "SystemMessage", - UiTimelineItem::PluginPanel(_) => "PluginPanel", - UiTimelineItem::LashlangCode(_) => "LashlangCode", - UiTimelineItem::Splash => "Splash", - } -} - -mod projection; -mod render; -mod runtime_state; diff --git a/crates/lash-cli/src/app/tests/projection.rs b/crates/lash-cli/src/app/tests/projection.rs deleted file mode 100644 index 5c15b17..0000000 --- a/crates/lash-cli/src/app/tests/projection.rs +++ /dev/null @@ -1,1211 +0,0 @@ -use super::*; - -#[test] -fn finish_turn_from_read_view_rebuilds_current_turn_from_authoritative_state() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.timeline - .push(UiTimelineItem::SystemMessage("Local note".into())); - let turn = PreparedTurn::new("What exists now?".into(), Vec::new()); - app.push_prepared_user_input(&turn); - app.start_turn(); - app.handle_session_event(SessionStreamEvent::TextDelta { - content: "I looked at the actual librarian prompt".into(), - }); - - let messages = vec![ - text_message("u1", MessageRole::User, "What exists now?"), - text_message( - "a1", - MessageRole::Assistant, - "I looked at the actual librarian prompt, the graph tool constraints.\n\n## What exists now", - ), - ]; - let events = events_from_messages(&messages); - app.finish_turn_from_read_view(&test_read_view(&events, &messages, &[])); - - assert_eq!(app.run_state, CliRunState::Idle); - assert!(app.timeline.iter().any(|block| { - matches!(block, UiTimelineItem::SystemMessage(text) if text == "Local note") - })); - let last_block = app - .timeline - .iter() - .rev() - .find_map(|block| match block { - UiTimelineItem::AssistantText(text) => Some(text.as_str()), - _ => None, - }) - .expect("assistant block"); - assert_eq!( - last_block, - "I looked at the actual librarian prompt, the graph tool constraints.\n\n## What exists now" - ); -} - -#[test] -fn finish_turn_from_read_view_preserves_local_system_messages_inside_active_turn() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let turn = PreparedTurn::new("Keep going".into(), Vec::new()); - app.push_prepared_user_input(&turn); - app.start_turn(); - app.timeline.push(UiTimelineItem::SystemMessage( - "Session info\nruntime: rlm".into(), - )); - app.handle_session_event(SessionStreamEvent::TextDelta { - content: "Runtime answer".into(), - }); - - let messages = vec![ - text_message("u1", MessageRole::User, "Keep going"), - text_message("a1", MessageRole::Assistant, "Runtime answer"), - ]; - let events = events_from_messages(&messages); - app.finish_turn_from_read_view(&test_read_view(&events, &messages, &[])); - - assert_eq!( - app.timeline - .iter() - .filter(|block| matches!( - block, - UiTimelineItem::SystemMessage(message) - if message == "Session info\nruntime: rlm" - )) - .count(), - 1 - ); - assert!(app.timeline.iter().any(|block| { - matches!(block, UiTimelineItem::AssistantText(text) if text == "Runtime answer") - })); -} - -#[test] -fn finish_turn_from_read_view_preserves_projected_turns_after_repeated_input() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let first_turn = PreparedTurn::new("hi".into(), Vec::new()); - app.push_prepared_user_input(&first_turn); - app.timeline - .push(UiTimelineItem::AssistantText("Hi.".into())); - app.start_turn(); - - let messages = vec![ - text_message("u1", MessageRole::User, "hi"), - text_message("a1", MessageRole::Assistant, "Hi."), - text_message("u2", MessageRole::User, "hi"), - text_message("a2", MessageRole::Assistant, "Hi."), - text_message("u3", MessageRole::User, "hi"), - text_message("a3", MessageRole::Assistant, "Hi."), - ]; - let events = events_from_messages(&messages); - - app.finish_turn_from_read_view(&test_read_view(&events, &messages, &[])); - - let user_inputs = app - .timeline - .iter() - .filter(|block| matches!(block, UiTimelineItem::UserInput(_))) - .count(); - let assistant_texts = app - .timeline - .iter() - .filter(|block| matches!(block, UiTimelineItem::AssistantText(_))) - .count(); - assert_eq!(user_inputs, 3); - assert_eq!(assistant_texts, 3); -} - -#[test] -fn finish_turn_from_read_view_does_not_duplicate_assistant_text_after_tool_activity() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let turn = PreparedTurn::new("Fix it".into(), Vec::new()); - app.push_prepared_user_input(&turn); - app.start_turn(); - app.handle_session_event(SessionStreamEvent::TextDelta { - content: "I found and fixed the 500.".into(), - }); - app.handle_session_event(SessionStreamEvent::ToolCall { - name: "update_plan".into(), - args: serde_json::json!({ - "plan": [ - {"step": "Inspect proxy", "status": "completed"}, - {"step": "Patch request body handling", "status": "completed"}, - {"step": "Verify auth fallback", "status": "completed"}, - ] - }), - output: lash_core::ToolCallOutput::success(serde_json::json!({"ok": true})), - duration_ms: 12, - call_id: Some("tc-plan".into()), - }); - - let messages = vec![ - text_message("u1", MessageRole::User, "Fix it"), - text_message("a1", MessageRole::Assistant, "I found and fixed the 500."), - ]; - let tool_calls = vec![ToolCallRecord { - call_id: Some("tc-plan".into()), - tool: "update_plan".into(), - args: serde_json::json!({ - "plan": [ - {"step": "Inspect proxy", "status": "completed"}, - {"step": "Patch request body handling", "status": "completed"}, - {"step": "Verify auth fallback", "status": "completed"}, - ] - }), - output: lash_core::ToolCallOutput::success(serde_json::json!({"ok": true})), - duration_ms: 12, - }]; - let events = events_from_messages(&messages); - app.finish_turn_from_read_view(&test_read_view(&events, &messages, &tool_calls)); - - let assistant_texts: Vec<&str> = app - .timeline - .iter() - .filter_map(|block| match block { - UiTimelineItem::AssistantText(text) => Some(text.as_str()), - _ => None, - }) - .collect(); - assert_eq!(assistant_texts, vec!["I found and fixed the 500."]); -} - -#[test] -fn finish_turn_from_read_view_uses_authoritative_reasoning_and_text() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let turn = PreparedTurn::new("Write a poem".into(), Vec::new()); - app.push_prepared_user_input(&turn); - app.start_turn(); - app.handle_session_event(SessionStreamEvent::ReasoningDelta { - content: "Crafting a cool poem".into(), - }); - app.handle_session_event(SessionStreamEvent::TextDelta { - content: "Neon rain on midnight street.".into(), - }); - app.handle_session_event(SessionStreamEvent::ReasoningDelta { - content: "I see the user wants a cool poem.".into(), - }); - - let message = Message { - id: "a1".into(), - role: MessageRole::Assistant, - parts: vec![ - part( - "a1.r", - PartKind::Reasoning, - "Crafting a cool poem\n\nI see the user wants a cool poem.", - ), - part("a1.t", PartKind::Text, "Neon rain on midnight street."), - ] - .into(), - origin: None, - }; - let messages = vec![ - text_message("u1", MessageRole::User, "Write a poem"), - message, - ]; - let events = events_from_messages(&messages); - app.finish_turn_from_read_view(&test_read_view(&events, &messages, &[])); - - let assistant_texts: Vec<&str> = app - .timeline - .iter() - .filter_map(|block| match block { - UiTimelineItem::AssistantText(text) => Some(text.as_str()), - _ => None, - }) - .collect(); - assert_eq!(assistant_texts, vec!["Neon rain on midnight street."]); - - let reasoning_blocks: Vec<&str> = app - .timeline - .iter() - .filter_map(|block| match block { - UiTimelineItem::AssistantReasoning(text) => Some(text.as_str()), - _ => None, - }) - .collect(); - assert_eq!(reasoning_blocks.len(), 1); - assert!(reasoning_blocks[0].contains("Crafting a cool poem")); - assert!(reasoning_blocks[0].contains("I see the user wants a cool poem.")); -} - -#[test] -fn live_reasoning_and_prose_deltas_are_committed_chronologically() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let turn = PreparedTurn::new("Inspect".into(), Vec::new()); - app.push_prepared_user_input(&turn); - app.start_turn(); - - app.handle_session_event(SessionStreamEvent::ReasoningDelta { - content: "first reasoning".into(), - }); - app.handle_session_event(SessionStreamEvent::TextDelta { - content: "visible prose".into(), - }); - app.handle_session_event(SessionStreamEvent::ReasoningDelta { - content: "second reasoning".into(), - }); - app.finalize_live_markdown(); - - let lane_blocks: Vec<(&str, &str)> = app - .timeline - .iter() - .filter_map(|block| match block { - UiTimelineItem::AssistantReasoning(text) => Some(("reasoning", text.as_str())), - UiTimelineItem::AssistantText(text) => Some(("prose", text.as_str())), - _ => None, - }) - .collect(); - assert_eq!( - lane_blocks, - vec![ - ("reasoning", "first reasoning"), - ("prose", "visible prose"), - ("reasoning", "second reasoning"), - ] - ); -} - -#[test] -fn read_view_timeline_preserves_assistant_part_order() { - let message = Message { - id: "a1".into(), - role: MessageRole::Assistant, - parts: vec![ - part("a1.t", PartKind::Text, "Visible answer."), - part("a1.r", PartKind::Reasoning, "Late summary."), - ] - .into(), - origin: None, - }; - - let events = events_from_messages(std::slice::from_ref(&message)); - let blocks = - timeline_items_from_test_read_view(&events, &[message], &[], &UiProjectionState::default()); - let variants: Vec<&str> = blocks.iter().map(other_variant_name).collect(); - assert_eq!(variants, vec!["AssistantText", "AssistantReasoning"]); -} - -#[test] -fn read_view_reasoning_renders_exactly_once() { - let message = Message { - id: "a1".into(), - role: MessageRole::Assistant, - parts: vec![ - part( - "a1.r", - PartKind::Reasoning, - "Planning the refactor in detail.", - ), - part("a1.t", PartKind::Text, "Done."), - ] - .into(), - origin: None, - }; - - let events = events_from_messages(std::slice::from_ref(&message)); - let blocks = - timeline_items_from_test_read_view(&events, &[message], &[], &UiProjectionState::default()); - let reasoning_count = blocks - .iter() - .filter(|item| matches!(item, projection::UiTimelineItem::AssistantReasoning(_))) - .count(); - assert_eq!( - reasoning_count, 1, - "read-view reasoning must render exactly once: {blocks:?}" - ); -} - -#[test] -fn read_view_timeline_round_trips_to_existing_display_blocks() { - let user = text_message("u1", MessageRole::User, "Summarize this"); - let assistant = text_message("a1", MessageRole::Assistant, "Summary."); - let events = events_from_messages(&[user.clone(), assistant.clone()]); - let ui_state = UiProjectionState::default(); - - let timeline = timeline_from_test_read_view(&events, &[user, assistant], &[], &ui_state); - let blocks = timeline_items_from_test_read_view( - &events, - &[ - text_message("u1", MessageRole::User, "Summarize this"), - text_message("a1", MessageRole::Assistant, "Summary."), - ], - &[], - &ui_state, - ); - - assert_eq!(timeline.items(), blocks.as_slice()); - let variants = timeline - .items() - .iter() - .map(|item| match item { - projection::UiTimelineItem::TurnStart(_) => "TurnStart", - projection::UiTimelineItem::UserInput(_) => "UserInput", - projection::UiTimelineItem::AssistantText(_) => "AssistantText", - projection::UiTimelineItem::AssistantReasoning(_) => "AssistantReasoning", - projection::UiTimelineItem::Activity(_) => "Activity", - projection::UiTimelineItem::ShellOutput { .. } => "ShellOutput", - projection::UiTimelineItem::Error(_) => "Error", - projection::UiTimelineItem::SystemMessage(_) => "SystemMessage", - projection::UiTimelineItem::PluginPanel(_) => "PluginPanel", - projection::UiTimelineItem::LashlangCode(_) => "LashlangCode", - projection::UiTimelineItem::Splash => "Splash", - }) - .collect::>(); - assert_eq!(variants, vec!["TurnStart", "UserInput", "AssistantText"]); -} - -#[test] -fn rlm_trajectory_reasoning_projects_as_assistant_reasoning() { - let assistant = assistant_reasoning_text_message("a1", "I'll reply directly.", ""); - let entry = lash_rlm_types::RlmTrajectoryEntry { - id: "lashlang_step_0".to_string(), - protocol_iteration: 0, - code: "finish \"hi\"".to_string(), - output: Vec::new(), - images: Vec::new(), - error: None, - final_output: None, - }; - let events = vec![ - conversation_event(assistant.clone()), - lash_core::SessionHistoryRecord::Protocol(lash_protocol_rlm::rlm_protocol_event( - lash_rlm_types::RlmProtocolEvent::RlmTrajectoryEntry(entry), - )), - ]; - - let blocks = timeline_items_from_test_read_view( - &events, - &[assistant], - &[], - &UiProjectionState::default(), - ); - let variants: Vec<&str> = blocks.iter().map(other_variant_name).collect(); - assert_eq!(variants, vec!["AssistantReasoning", "LashlangCode"]); - - let reasoning = blocks.iter().find_map(|block| match block { - UiTimelineItem::AssistantReasoning(text) => Some(text.as_str()), - _ => None, - }); - assert_eq!(reasoning, Some("I'll reply directly.")); -} - -#[test] -fn rlm_trajectory_projects_reasoning_prose_code_and_error_in_order() { - let assistant = assistant_reasoning_text_message("a1", "private planning", "visible status"); - let entry = lash_rlm_types::RlmTrajectoryEntry { - id: "lashlang_step_0".to_string(), - protocol_iteration: 0, - code: "missing_name".to_string(), - output: Vec::new(), - images: Vec::new(), - error: Some("unknown variable `missing_name`".to_string()), - final_output: None, - }; - let events = vec![ - conversation_event(assistant.clone()), - lash_core::SessionHistoryRecord::Protocol(lash_protocol_rlm::rlm_protocol_event( - lash_rlm_types::RlmProtocolEvent::RlmTrajectoryEntry(entry), - )), - ]; - - let blocks = timeline_items_from_test_read_view( - &events, - &[assistant], - &[], - &UiProjectionState::default(), - ); - let variants: Vec<&str> = blocks.iter().map(other_variant_name).collect(); - assert_eq!( - variants, - vec![ - "AssistantReasoning", - "AssistantText", - "LashlangCode", - "Error" - ] - ); - assert!(matches!( - &blocks[0], - UiTimelineItem::AssistantReasoning(text) if text == "private planning" - )); - assert!(matches!( - &blocks[1], - UiTimelineItem::AssistantText(text) if text == "visible status" - )); - assert!(matches!( - &blocks[2], - UiTimelineItem::LashlangCode(text) if text == "missing_name" - )); - assert!(matches!( - &blocks[3], - UiTimelineItem::Error(text) if text == "unknown variable `missing_name`" - )); -} - -#[test] -fn rlm_trajectory_final_output_projects_as_assistant_text() { - let assistant = assistant_reasoning_text_message("a1", "I'll reply directly.", ""); - let entry = lash_rlm_types::RlmTrajectoryEntry { - id: "lashlang_step_0".to_string(), - protocol_iteration: 0, - code: "finish \"Hi!\"".to_string(), - output: Vec::new(), - images: Vec::new(), - error: None, - final_output: Some(serde_json::json!("Hi!")), - }; - let events = vec![ - conversation_event(assistant.clone()), - lash_core::SessionHistoryRecord::Protocol(lash_protocol_rlm::rlm_protocol_event( - lash_rlm_types::RlmProtocolEvent::RlmTrajectoryEntry(entry), - )), - ]; - - let blocks = timeline_items_from_test_read_view( - &events, - &[assistant], - &[], - &UiProjectionState::default(), - ); - let variants: Vec<&str> = blocks.iter().map(other_variant_name).collect(); - assert_eq!( - variants, - vec!["AssistantReasoning", "LashlangCode", "AssistantText"] - ); - let answer = blocks.iter().find_map(|block| match block { - UiTimelineItem::AssistantText(text) => Some(text.as_str()), - _ => None, - }); - assert_eq!(answer, Some("Hi!")); -} - -#[test] -fn rlm_trajectory_null_final_output_keeps_prose_and_suppresses_final_value() { - let assistant = assistant_reasoning_text_message("a1", "", "Answer in streamed prose."); - let entry = lash_rlm_types::RlmTrajectoryEntry { - id: "lashlang_step_0".to_string(), - protocol_iteration: 0, - code: "finish".to_string(), - output: Vec::new(), - images: Vec::new(), - error: None, - final_output: Some(serde_json::Value::Null), - }; - let events = vec![ - conversation_event(assistant.clone()), - lash_core::SessionHistoryRecord::Protocol(lash_protocol_rlm::rlm_protocol_event( - lash_rlm_types::RlmProtocolEvent::RlmTrajectoryEntry(entry), - )), - ]; - - let blocks = timeline_items_from_test_read_view( - &events, - &[assistant], - &[], - &UiProjectionState::default(), - ); - let variants: Vec<&str> = blocks.iter().map(other_variant_name).collect(); - assert_eq!(variants, vec!["AssistantText", "LashlangCode"]); - - let assistant_texts: Vec<&str> = blocks - .iter() - .filter_map(|block| match block { - UiTimelineItem::AssistantText(text) => Some(text.as_str()), - _ => None, - }) - .collect(); - assert_eq!(assistant_texts, vec!["Answer in streamed prose."]); -} - -#[test] -fn rlm_final_answer_projects_after_reasoning_and_lashlang_code() { - let user = text_message("u1", MessageRole::User, "hi"); - let assistant_reasoning = assistant_reasoning_text_message("a0", "I'll answer directly.", ""); - let assistant = plugin_text_message("a1", MessageRole::Assistant, "rlm_protocol", "Hi!"); - let entry = lash_rlm_types::RlmTrajectoryEntry { - id: "lashlang_step_0".to_string(), - protocol_iteration: 0, - code: "finish \"Hi!\"".to_string(), - output: Vec::new(), - images: Vec::new(), - error: None, - final_output: Some(serde_json::json!("Hi!")), - }; - let events = vec![ - conversation_event(user.clone()), - conversation_event(assistant_reasoning.clone()), - lash_core::SessionHistoryRecord::Protocol(lash_protocol_rlm::rlm_protocol_event( - lash_rlm_types::RlmProtocolEvent::RlmTrajectoryEntry(entry), - )), - conversation_event(assistant.clone()), - ]; - - let blocks = timeline_items_from_test_read_view( - &events, - &[user, assistant_reasoning, assistant], - &[], - &UiProjectionState::default(), - ); - let variants: Vec<&str> = blocks.iter().map(other_variant_name).collect(); - assert_eq!( - variants, - vec![ - "TurnStart", - "UserInput", - "AssistantReasoning", - "LashlangCode", - "AssistantText", - ] - ); - let assistant_texts: Vec<&str> = blocks - .iter() - .filter_map(|block| match block { - UiTimelineItem::AssistantText(text) => Some(text.as_str()), - _ => None, - }) - .collect(); - assert_eq!(assistant_texts, vec!["Hi!"]); -} - -#[test] -fn finish_turn_replaces_live_final_value_with_projection() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let turn = PreparedTurn::new("What time is it".into(), Vec::new()); - app.push_prepared_user_input(&turn); - app.start_turn(); - app.handle_turn_activity(TurnActivity::independent(TurnEvent::FinalValue { - value: serde_json::json!("Current system time: Fri May 15 11:35:52 PM CEST 2026"), - })); - - let user = text_message("u1", MessageRole::User, "What time is it"); - let entry = lash_rlm_types::RlmTrajectoryEntry { - id: "lashlang_step_0".to_string(), - protocol_iteration: 0, - code: "finish \"Current system time: Fri May 15 11:35:52 PM CEST 2026\"".to_string(), - output: Vec::new(), - images: Vec::new(), - error: None, - final_output: Some(serde_json::json!( - "Current system time: Fri May 15 11:35:52 PM CEST 2026" - )), - }; - let events = vec![ - conversation_event(user.clone()), - lash_core::SessionHistoryRecord::Protocol(lash_protocol_rlm::rlm_protocol_event( - lash_rlm_types::RlmProtocolEvent::RlmTrajectoryEntry(entry), - )), - ]; - - app.finish_turn_from_read_view(&test_read_view(&events, &[user], &[])); - - let assistant_texts: Vec<&str> = app - .timeline - .iter() - .filter_map(|block| match block { - UiTimelineItem::AssistantText(text) => Some(text.as_str()), - _ => None, - }) - .collect(); - assert_eq!( - assistant_texts, - vec!["Current system time: Fri May 15 11:35:52 PM CEST 2026"] - ); -} - -#[test] -fn final_value_turn_event_projects_as_assistant_text() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.handle_turn_activity(TurnActivity::independent(TurnEvent::FinalValue { - value: serde_json::json!("**done**"), - })); - - assert!(matches!( - app.timeline.last(), - Some(UiTimelineItem::AssistantText(text)) if text == "**done**" - )); -} - -#[test] -fn null_final_value_turn_event_does_not_project_as_assistant_text() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.handle_turn_activity(TurnActivity::independent(TurnEvent::FinalValue { - value: serde_json::Value::Null, - })); - - assert!( - !app.timeline - .iter() - .any(|block| matches!(block, UiTimelineItem::AssistantText(_))) - ); -} - -#[test] -fn rlm_trajectory_projects_reasoning_and_code_without_hidden_tool_calls() { - let assistant = assistant_reasoning_text_message("a1", "I'll inspect the environment.", ""); - let entry = lash_rlm_types::RlmTrajectoryEntry { - id: "lashlang_step_0".to_string(), - protocol_iteration: 0, - code: "now = await shell.exec({ cmd: \"date -u\" })?\nprint now".to_string(), - output: vec!["2026-04-25 20:05:57 UTC".to_string()], - images: Vec::new(), - error: None, - final_output: None, - }; - let events = vec![ - conversation_event(assistant.clone()), - lash_core::SessionHistoryRecord::Protocol(lash_protocol_rlm::rlm_protocol_event( - lash_rlm_types::RlmProtocolEvent::RlmTrajectoryEntry(entry), - )), - ]; - - let blocks = timeline_items_from_test_read_view( - &events, - &[assistant], - &[], - &UiProjectionState::default(), - ); - let variants: Vec<&str> = blocks.iter().map(other_variant_name).collect(); - assert_eq!(variants, vec!["AssistantReasoning", "LashlangCode"]); -} - -#[test] -fn rlm_trajectory_final_output_does_not_inline_hidden_tool_call_projection() { - let entry = lash_rlm_types::RlmTrajectoryEntry { - id: "lashlang_step_0".to_string(), - protocol_iteration: 0, - code: "now = await shell.exec({ cmd: \"date\" })?\nfinish trim(now.output)".to_string(), - output: Vec::new(), - images: Vec::new(), - error: None, - final_output: Some(serde_json::json!("Mon May 11 01:51:25 PM CEST 2026")), - }; - let events = vec![lash_core::SessionHistoryRecord::Protocol( - lash_protocol_rlm::rlm_protocol_event( - lash_rlm_types::RlmProtocolEvent::RlmTrajectoryEntry(entry), - ), - )]; - - let blocks = - timeline_items_from_test_read_view(&events, &[], &[], &UiProjectionState::default()); - let variants: Vec<&str> = blocks.iter().map(other_variant_name).collect(); - assert_eq!(variants, vec!["LashlangCode", "AssistantText"]); - assert_eq!( - blocks - .iter() - .filter(|block| matches!(block, UiTimelineItem::Activity(_))) - .count(), - 0 - ); -} - -#[test] -fn rlm_activity_journal_projects_tool_rows_after_matching_lashlang_block() { - let user = text_message("u1", MessageRole::User, "What time is it?"); - let entry = lash_rlm_types::RlmTrajectoryEntry { - id: "lashlang_step_0".to_string(), - protocol_iteration: 0, - code: "now = await shell.exec({ cmd: \"date\" })?\nfinish trim(now.output)".to_string(), - output: Vec::new(), - images: Vec::new(), - error: None, - final_output: Some(serde_json::json!("Sunday, June 21, 2026")), - }; - let events = vec![ - conversation_event(user.clone()), - lash_core::SessionHistoryRecord::Protocol(lash_protocol_rlm::rlm_protocol_event( - lash_rlm_types::RlmProtocolEvent::RlmTrajectoryEntry(entry), - )), - ]; - let mut activity_journal = UiActivityJournal::default(); - activity_journal.apply_record(UiActivityRecord::new( - 0, - 0, - ActivityBlock::new( - ActivityKind::GenericTool, - "exec_command", - serde_json::json!({"cmd": "date"}), - "Run date", - ActivityStatus::Completed, - serde_json::json!({"exit_code": 0, "stdout": "Sunday, June 21, 2026\n"}), - 8, - ) - .with_call_id(Some("lashlang:tool-0".to_string())), - )); - - let blocks = timeline_items_from_test_read_view( - &events, - &[user], - &[], - &UiProjectionState { - activity_journal, - ..UiProjectionState::default() - }, - ); - let variants: Vec<&str> = blocks.iter().map(other_variant_name).collect(); - assert_eq!( - variants, - vec![ - "TurnStart", - "UserInput", - "LashlangCode", - "Activity", - "AssistantText", - ] - ); - assert!(matches!( - &blocks[3], - UiTimelineItem::Activity(activity) - if activity.call.tool_name == "exec_command" - && activity.result.status == ActivityStatus::Completed - )); -} - -#[test] -fn finish_turn_from_read_view_preserves_live_lashlang_tool_activity_from_cli_journal() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let turn = PreparedTurn::new("What time is it?".into(), Vec::new()); - app.push_prepared_user_input(&turn); - app.start_turn(); - // Tool containment attaches by graph key: the tool call carries the same - // `graph_key` as its enclosing code block, so it journals under the block - // without any ordering heuristic. - let graph_key = "effect:test-session-id:turn:1".to_string(); - app.handle_turn_activity(TurnActivity::independent(TurnEvent::CodeBlockStarted { - language: "lashlang".to_string(), - code: "now = await tools.exec_command({ cmd: \"date\" })?\nfinish trim(now.output)" - .to_string(), - graph_key: Some(graph_key.clone()), - })); - app.handle_turn_activity(TurnActivity::independent(TurnEvent::ToolCallStarted { - call_id: Some("lashlang:tool-0".to_string()), - name: "exec_command".to_string(), - args: serde_json::json!({"cmd": "date"}), - graph_key: Some(graph_key.clone()), - parent_call_id: None, - })); - app.handle_turn_activity(TurnActivity::independent(TurnEvent::ToolCallCompleted { - call_id: Some("lashlang:tool-0".to_string()), - name: "exec_command".to_string(), - args: serde_json::json!({"cmd": "date"}), - output: lash_core::ToolCallOutput::success(serde_json::json!({ - "exit_code": 0, - "stdout": "Sunday, June 21, 2026\n" - })), - duration_ms: 8, - graph_key: Some(graph_key.clone()), - parent_call_id: None, - })); - app.handle_turn_activity(TurnActivity::independent(TurnEvent::CodeBlockCompleted { - language: "lashlang".to_string(), - output: String::new(), - graph_key: Some(graph_key.clone()), - success: true, - error: None, - duration_ms: 8, - tool_call_ids: vec!["lashlang:tool-0".to_string()], - })); - - let user = text_message("u1", MessageRole::User, "What time is it?"); - let entry = lash_rlm_types::RlmTrajectoryEntry { - id: "lashlang_step_0".to_string(), - protocol_iteration: 0, - code: "now = await tools.exec_command({ cmd: \"date\" })?\nfinish trim(now.output)" - .to_string(), - output: Vec::new(), - images: Vec::new(), - error: None, - final_output: Some(serde_json::json!("Sunday, June 21, 2026")), - }; - let events = vec![ - conversation_event(user.clone()), - lash_core::SessionHistoryRecord::Protocol(lash_protocol_rlm::rlm_protocol_event( - lash_rlm_types::RlmProtocolEvent::RlmTrajectoryEntry(entry), - )), - ]; - - app.finish_turn_from_read_view(&test_read_view(&events, &[user], &[])); - - let variants: Vec<&str> = app.timeline.iter().map(other_variant_name).collect(); - assert_eq!( - variants, - vec![ - "TurnStart", - "UserInput", - "LashlangCode", - "Activity", - "AssistantText", - ] - ); - assert!(matches!( - app.timeline.get(3), - Some(UiTimelineItem::Activity(activity)) - if activity.call.call_id.as_deref() == Some("lashlang:tool-0") - && activity.result.status == ActivityStatus::Completed - )); -} - -#[test] -fn rlm_trajectory_steps_project_chronologically_without_hidden_tool_results() { - let first = lash_rlm_types::RlmTrajectoryEntry { - id: "lashlang_step_0".to_string(), - protocol_iteration: 0, - code: "now = await shell.exec({ cmd: \"date -u\" })?\nprint now".to_string(), - output: vec!["time".to_string()], - images: Vec::new(), - error: None, - final_output: None, - }; - let second = lash_rlm_types::RlmTrajectoryEntry { - id: "lashlang_step_1".to_string(), - protocol_iteration: 1, - code: "files = await tools.glob({ pattern: \"*\", path: \".\" })?\nprint files".to_string(), - output: vec!["files".to_string()], - images: Vec::new(), - error: None, - final_output: None, - }; - let assistant = text_message("a1", MessageRole::Assistant, "Done."); - let first_reasoning = assistant_reasoning_text_message("a-first", "First check the time.", ""); - let second_reasoning = assistant_reasoning_text_message("a-second", "Then check files.", ""); - let events = vec![ - conversation_event(first_reasoning.clone()), - lash_core::SessionHistoryRecord::Protocol(lash_protocol_rlm::rlm_protocol_event( - lash_rlm_types::RlmProtocolEvent::RlmTrajectoryEntry(first), - )), - conversation_event(second_reasoning.clone()), - lash_core::SessionHistoryRecord::Protocol(lash_protocol_rlm::rlm_protocol_event( - lash_rlm_types::RlmProtocolEvent::RlmTrajectoryEntry(second), - )), - conversation_event(assistant.clone()), - ]; - - let blocks = timeline_items_from_test_read_view( - &events, - &[first_reasoning, second_reasoning, assistant], - &[], - &UiProjectionState::default(), - ); - let variants: Vec<&str> = blocks.iter().map(other_variant_name).collect(); - assert_eq!( - variants, - vec![ - "AssistantReasoning", - "LashlangCode", - "AssistantReasoning", - "LashlangCode", - "AssistantText", - ] - ); -} - -#[test] -fn finish_turn_from_read_view_uses_authoritative_transcript_even_when_streamed_text_differs() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let turn = PreparedTurn::new("Shorten it".into(), Vec::new()); - app.push_prepared_user_input(&turn); - app.start_turn(); - app.handle_session_event(SessionStreamEvent::TextDelta { - content: "Visible streamed text".into(), - }); - - let messages = vec![ - text_message("u1", MessageRole::User, "Shorten it"), - text_message("a1", MessageRole::Assistant, "Visible"), - ]; - let events = events_from_messages(&messages); - app.finish_turn_from_read_view(&test_read_view(&events, &messages, &[])); - - let last_block = app - .timeline - .iter() - .rev() - .find_map(|block| match block { - UiTimelineItem::AssistantText(text) => Some(text.as_str()), - _ => None, - }) - .expect("assistant block"); - assert_eq!(last_block, "Visible"); -} - -#[test] -fn finish_interrupted_turn_from_read_view_preserves_local_system_messages() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.timeline.push(UiTimelineItem::SystemMessage( - "Session info\nruntime: rlm".into(), - )); - let turn = PreparedTurn::new("Keep working".into(), Vec::new()); - app.push_prepared_user_input(&turn); - app.start_turn(); - app.handle_session_event(SessionStreamEvent::TextDelta { - content: "Partial answer".into(), - }); - - let messages = vec![text_message("u1", MessageRole::User, "Keep working")]; - let events = events_from_messages(&messages); - let ui_state = app.ui_projection_state(); - app.finish_interrupted_turn_from_read_view( - &test_read_view(&events, &messages, &[]), - &ui_state, - "Cancelled.", - ); - - assert_eq!(app.run_state, CliRunState::Idle); - assert_eq!( - app.timeline - .iter() - .filter(|block| matches!( - block, - UiTimelineItem::SystemMessage(message) - if message == "Session info\nruntime: rlm" - )) - .count(), - 1 - ); - assert!(!app.timeline.iter().any(|block| matches!( - block, - UiTimelineItem::AssistantText(text) if text == "Partial answer" - ))); - assert!(matches!( - app.timeline.last(), - Some(UiTimelineItem::SystemMessage(message)) if message == "Cancelled." - )); -} - -#[test] -fn cancel_then_queued_turn_uses_read_view_without_preview_duplication() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let prior_messages = vec![ - text_message("u0", MessageRole::User, "Earlier question"), - text_message("a0", MessageRole::Assistant, "Prior durable answer"), - ]; - let prior_events = events_from_messages(&prior_messages); - app.finish_turn_from_read_view(&test_read_view(&prior_events, &prior_messages, &[])); - - let cancelled_turn = PreparedTurn::new("Cancel this".into(), Vec::new()); - app.push_prepared_user_input(&cancelled_turn); - app.start_turn(); - app.handle_session_event(SessionStreamEvent::TextDelta { - content: "Cancelled live preview".into(), - }); - - let cancelled_messages = vec![ - text_message("u0", MessageRole::User, "Earlier question"), - text_message("a0", MessageRole::Assistant, "Prior durable answer"), - text_message("u1", MessageRole::User, "Cancel this"), - ]; - let cancelled_events = events_from_messages(&cancelled_messages); - let ui_state = app.ui_projection_state(); - app.finish_interrupted_turn_from_read_view( - &test_read_view(&cancelled_events, &cancelled_messages, &[]), - &ui_state, - "Cancelled.", - ); - - let queued_turn = PreparedTurn::new("Queued follow-up".into(), Vec::new()); - app.push_prepared_user_input(&queued_turn); - app.start_turn(); - let final_messages = vec![ - text_message("u0", MessageRole::User, "Earlier question"), - text_message("a0", MessageRole::Assistant, "Prior durable answer"), - text_message("u1", MessageRole::User, "Cancel this"), - text_message("u2", MessageRole::User, "Queued follow-up"), - text_message("a2", MessageRole::Assistant, "Queued durable answer"), - ]; - let final_events = events_from_messages(&final_messages); - app.finish_turn_from_read_view(&test_read_view(&final_events, &final_messages, &[])); - - let assistant_texts = app - .timeline - .iter() - .filter_map(|block| match block { - UiTimelineItem::AssistantText(text) => Some(text.as_str()), - _ => None, - }) - .collect::>(); - assert_eq!( - assistant_texts, - vec!["Prior durable answer", "Queued durable answer"] - ); - assert!( - !app.timeline.iter().any(|block| matches!( - block, - UiTimelineItem::AssistantText(text) if text == "Cancelled live preview" - )), - "cancelled live preview must not survive into the queued follow-up projection" - ); -} - -#[test] -fn interrupted_read_view_discards_non_durable_assistant_text() { - let blocks = interrupted_blocks_from_test_read_view( - &[], - &[], - &[], - &UiProjectionState::default(), - "Cancelled.", - ); - - assert!( - !blocks - .iter() - .any(|block| matches!(block, UiTimelineItem::AssistantText(_))), - "interrupted projection must not synthesize assistant text from live buffers: {blocks:#?}" - ); - assert!(matches!( - blocks.last(), - Some(UiTimelineItem::SystemMessage(msg)) if msg == "Cancelled." - )); -} - -#[test] -fn interrupted_read_view_renders_committed_prose_exactly_once() { - let messages = vec![ - text_message("m0", MessageRole::User, "go"), - text_message("m1", MessageRole::Assistant, "first prose"), - text_message("m2", MessageRole::Assistant, "second prose"), - ]; - let events = events_from_messages(&messages); - let blocks = interrupted_blocks_from_test_read_view( - &events, - &messages, - &[], - &UiProjectionState::default(), - "Cancelled.", - ); - - let assistant_texts: Vec<&str> = blocks - .iter() - .filter_map(|block| match block { - UiTimelineItem::AssistantText(text) => Some(text.as_str()), - _ => None, - }) - .collect(); - assert_eq!(assistant_texts, vec!["first prose", "second prose"]); -} - -#[test] -fn interrupted_read_view_never_appends_uncommitted_tail() { - let messages = vec![text_message("m0", MessageRole::Assistant, "first prose")]; - let events = events_from_messages(&messages); - let blocks = interrupted_blocks_from_test_read_view( - &events, - &messages, - &[], - &UiProjectionState::default(), - "Cancelled.", - ); - - let assistant_texts: Vec<&str> = blocks - .iter() - .filter_map(|block| match block { - UiTimelineItem::AssistantText(text) => Some(text.as_str()), - _ => None, - }) - .collect(); - assert_eq!(assistant_texts, vec!["first prose"]); -} - -#[test] -fn interrupted_read_view_hides_rlm_finish_reminder_system_messages() { - let messages = vec![ - text_message("m0", MessageRole::User, "What time is it?"), - text_message("m1", MessageRole::Assistant, "Checking the system time."), - plugin_text_message( - "m2", - MessageRole::System, - "rlm_protocol", - "Your prose was recorded, but this turn requires an explicit final value. Add a paired `...` block containing `finish `. Use `finish null` only when null is intentional.", - ), - ]; - let events = events_from_messages(&messages); - let blocks = interrupted_blocks_from_test_read_view( - &events, - &messages, - &[], - &UiProjectionState::default(), - crate::util::manual_interrupt_message(), - ); - - assert!( - !blocks.iter().any(|block| matches!( - block, - UiTimelineItem::SystemMessage(text) if text.contains("explicit final value") - )), - "RLM protocol finish reminder leaked into interrupted UI: {blocks:#?}" - ); - assert!(blocks.iter().any(|block| matches!( - block, - UiTimelineItem::AssistantText(text) if text == "Checking the system time." - ))); - assert!(matches!( - blocks.last(), - Some(UiTimelineItem::SystemMessage(text)) if text == crate::util::manual_interrupt_message() - )); -} - -#[test] -fn interrupted_read_view_hides_rlm_execution_result_user_message() { - let result_message = Message { - id: "m1".to_string(), - role: MessageRole::User, - parts: vec![Part { - // Legacy RLM exec results were stored as user text with - // `tool_call_id` + `tool_name` preserved. Read-model rendering should - // hide them without rendering a fake execute_lashlang - // activity. - id: "m1.p0".to_string(), - kind: PartKind::Text, - content: "[Lashlang execution result]\n\nobservations:\nraw dump".to_string(), - attachment: None, - tool_call_id: Some("rlm_exec_0".to_string()), - tool_name: Some("execute_lashlang".to_string()), - tool_replay: None, - prune_state: PruneState::Intact, - reasoning_meta: None, - response_meta: None, - }] - .into(), - origin: None, - }; - let tool_calls = vec![ToolCallRecord { - call_id: Some("rlm_exec_0".to_string()), - tool: "execute_lashlang".to_string(), - args: serde_json::json!({ "code": "print inspect" }), - output: lash_core::ToolCallOutput::success(serde_json::json!({ - "observations": "raw dump", - "tool_calls": [] - })), - duration_ms: 0, - }]; - let messages = vec![text_message("m0", MessageRole::User, "go"), result_message]; - let events = events_from_messages(&messages); - let blocks = interrupted_blocks_from_test_read_view( - &events, - &messages, - &tool_calls, - &UiProjectionState::default(), - "Cancelled.", - ); - - assert!(blocks.iter().all(|block| { - !matches!(block, UiTimelineItem::UserInput(text) if text.contains("[Lashlang execution result]")) - })); - assert!(blocks.iter().all(|block| { - !matches!( - block, - UiTimelineItem::Activity(activity) if activity.call.tool_name == "execute_lashlang" - ) - })); -} - -#[test] -fn ui_projection_state_omits_transient_live_turn() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.start_turn(); - app.set_status(CliRunState::Waiting, Some("in 5s".into()), true); - if let Some(turn) = app.live.turn.as_mut() { - turn.has_visible_output = true; - } - - let persisted = serde_json::to_value(app.ui_projection_state()).expect("serialize ui"); - assert!(persisted.get("live_turn").is_none()); -} diff --git a/crates/lash-cli/src/app/tests/render.rs b/crates/lash-cli/src/app/tests/render.rs deleted file mode 100644 index cc34a28..0000000 --- a/crates/lash-cli/src/app/tests/render.rs +++ /dev/null @@ -1,1357 +0,0 @@ -use super::*; - -#[test] -fn background_subagent_status_is_transient_and_freezes_duration() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let running = lash_core::ProcessHandleSummary { - handle_type: "process".to_string(), - id: "subagent:smoke".to_string(), - process_id: "subagent:smoke".to_string(), - descriptor: lash_core::ProcessHandleDescriptor::new(Some("subagent"), Some("smoke")), - definition: None, - status: lash_core::ProcessLifecycleStatus::Running, - }; - app.update_processes(vec![running]); - app.processes[0].first_seen = std::time::Instant::now() - std::time::Duration::from_secs(125); - assert_eq!(app.processes.len(), 1); - assert_eq!(app.processes[0].status_duration, None); - - let completed = lash_core::ProcessHandleSummary { - handle_type: "process".to_string(), - id: "subagent:smoke".to_string(), - process_id: "subagent:smoke".to_string(), - descriptor: lash_core::ProcessHandleDescriptor::new(Some("subagent"), Some("smoke")), - definition: None, - status: lash_core::ProcessLifecycleStatus::Completed, - }; - app.update_processes(vec![completed]); - - assert_eq!(app.processes.len(), 1); - assert_eq!( - app.processes[0].status.terminal_state(), - Some(lash_core::ProcessTerminalState::Completed) - ); - assert!(app.processes[0].transient_until.is_some()); - assert!( - app.processes[0] - .status_duration - .is_some_and(|duration| duration.as_secs() >= 125) - ); -} - -#[test] -fn process_selection_tracks_visible_process_snapshot() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let running = lash_core::ProcessHandleSummary::new( - "process-1", - lash_core::ProcessHandleDescriptor::new(Some("lashlang"), Some("responder")), - lash_core::ProcessLifecycleStatus::Running, - ) - .with_definition(Some( - serde_json::to_value(lashlang::ProcessDefinitionIdentity::new( - lashlang::ModuleRef::new(&lashlang::ContentHash::new("module")), - lashlang::HostRequirementsRef::new(&lashlang::ContentHash::new("host")), - lashlang::ProcessRef::new(lashlang::ContentHash::new("process"), 1), - "responder", - )) - .expect("process definition serializes"), - )); - - app.update_processes(vec![running.clone()]); - assert!(app.select_next_process()); - assert_eq!( - app.selected_process() - .map(|process| process.process_id.as_str()), - Some("process-1") - ); - let overview = app - .selected_process_overview_state() - .expect("process overview"); - assert_eq!(overview.title, "Process responder"); - assert!( - overview - .rows - .iter() - .any(|(label, value)| { label == "definition" && value == "responder" }) - ); - - app.update_processes(vec![running]); - assert_eq!( - app.selected_process() - .map(|process| process.process_id.as_str()), - Some("process-1") - ); - - app.update_processes(Vec::new()); - assert!(app.selected_process().is_none()); -} - -#[test] -fn selected_process_row_is_focusable_and_renders_definition() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.update_processes(vec![ - lash_core::ProcessHandleSummary::new( - "process-1", - lash_core::ProcessHandleDescriptor::new(Some("lashlang"), Some("responder")), - lash_core::ProcessLifecycleStatus::Running, - ) - .with_definition(Some( - serde_json::to_value(lashlang::ProcessDefinitionIdentity::new( - lashlang::ModuleRef::new(&lashlang::ContentHash::new("module")), - lashlang::HostRequirementsRef::new(&lashlang::ContentHash::new("host")), - lashlang::ProcessRef::new(lashlang::ContentHash::new("process"), 1), - "responder", - )) - .expect("process definition serializes"), - )), - ]); - app.select_next_process(); - - let lines = crate::render::process_lines_snapshot(&app, 80).expect("process lines"); - let text: Vec = lines - .iter() - .map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) - .collect(); - - assert!(text.iter().any(|line| { - line.contains("▶ SELECTED") && line.contains("running · responder · responder") - })); - let selected_row = lines - .iter() - .find(|line| { - line.spans - .iter() - .any(|span| span.content.as_ref().contains("SELECTED")) - }) - .expect("selected process row"); - assert!( - selected_row - .spans - .iter() - .any(|span| span.style.bg == Some(crate::theme::SELECTION_BG)), - "selected process row should carry a visible background highlight" - ); -} - -#[test] -fn process_cancel_observation_updates_visible_dock_before_snapshot() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.update_processes(vec![ - lash_core::ProcessHandleSummary::new( - "old-port", - lash_core::ProcessHandleDescriptor::new(Some("shell"), Some("0.0.0.0:345")), - lash_core::ProcessLifecycleStatus::Running, - ), - lash_core::ProcessHandleSummary::new( - "new-port", - lash_core::ProcessHandleDescriptor::new(Some("shell"), Some("0.0.0.0:3048")), - lash_core::ProcessLifecycleStatus::Running, - ), - ]); - app.processes[0].first_seen = std::time::Instant::now() - std::time::Duration::from_secs(61); - - app.apply_process_changed( - lash_core::SessionProcessEventKind::Cancelled, - &[String::from("old-port")], - ); - - let lines = crate::render::process_lines_snapshot(&app, 80).expect("process lines"); - let text: Vec = lines - .iter() - .map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) - .collect(); - - assert!(text.iter().any(|line| { - line.contains("cancelled") && line.contains("shell") && line.contains("0.0.0.0:345") - })); - assert!( - !text.iter().any(|line| { - line.contains("running") && line.contains("shell") && line.contains("0.0.0.0:345") - }), - "cancelled process must not keep rendering as running" - ); - assert!(text.iter().any(|line| { - line.contains("running") && line.contains("shell") && line.contains("0.0.0.0:3048") - })); - assert!( - app.processes - .iter() - .find(|process| process.process_id == "old-port") - .expect("old process") - .status_duration - .is_some_and(|duration| duration.as_secs() >= 61) - ); -} - -#[test] -fn process_rows_are_input_chrome_not_history_content() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let baseline_height = app.total_content_height(80, 20); - app.update_processes(vec![lash_core::ProcessHandleSummary::new( - "process-1", - lash_core::ProcessHandleDescriptor::new(Some("lashlang"), Some("responder")), - lash_core::ProcessLifecycleStatus::Running, - )]); - - assert_eq!(crate::render::process_dock_height(&app, 80), 2); - assert_eq!(app.total_content_height(80, 20), baseline_height); - - let areas = crate::render::chrome_areas(&app, 80, 24); - assert_eq!(areas.process.height, 2); - assert_eq!(areas.status.y, areas.input.y + areas.input.height); - assert_eq!(areas.process.y, areas.status.y + areas.status.height); -} - -#[test] -fn stop_turn_marks_idle_redraw_dirty() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.start_turn(); - app.dirty = false; - - app.stop_turn(); - - assert_eq!(app.run_state, CliRunState::Idle); - assert!(app.dirty); -} - -#[test] -fn text_delta_accumulates_raw() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.handle_session_event(SessionStreamEvent::TextDelta { - content: "\n\nfirst\n".into(), - }); - assert_eq!( - app.live.assistant.normalized_text().as_deref(), - Some("first") - ); - - app.handle_session_event(SessionStreamEvent::TextDelta { - content: "\n\n\nsecond\n".into(), - }); - assert_eq!( - app.live.assistant.normalized_text().as_deref(), - Some("first\n\nsecond") - ); -} - -#[test] -fn text_delta_code_fence_preserved() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.handle_session_event(SessionStreamEvent::TextDelta { - content: "text\n\n```python\n".into(), - }); - app.handle_session_event(SessionStreamEvent::TextDelta { - content: "# comment\n".into(), - }); - // The newline between ```python and # comment must be preserved - assert!( - app.live - .assistant - .normalized_text() - .is_some_and(|text| text.contains("```python\n# comment")) - ); -} - -#[test] -fn text_delta_stays_in_live_assistant_until_committed() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.start_turn(); - - app.handle_session_event(SessionStreamEvent::TextDelta { - content: "Draft answer".into(), - }); - - assert!(app.timeline.is_empty()); - assert_eq!( - app.live.assistant.normalized_text().as_deref(), - Some("Draft answer") - ); -} - -#[test] -fn model_attempt_reset_retracts_failed_streamed_output() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.start_turn(); - app.handle_turn_activity(TurnActivity::independent(TurnEvent::ModelRequestStarted { - protocol_iteration: 0, - })); - app.handle_turn_activity(TurnActivity::new( - lash_core::TurnActivityId::new("failed-reasoning"), - TurnEvent::ReasoningDelta { - text: "discarded reasoning".into(), - }, - )); - app.handle_turn_activity(TurnActivity::new( - lash_core::TurnActivityId::new("failed-prose"), - TurnEvent::AssistantProseDelta { - text: "discarded answer".into(), - }, - )); - - assert!(matches!( - app.timeline.last(), - Some(UiTimelineItem::AssistantReasoning(text)) if text == "discarded reasoning" - )); - app.handle_turn_activity(TurnActivity::independent(TurnEvent::ModelAttemptReset { - assistant_prose_correlation_ids: vec![lash_core::TurnActivityId::new("failed-prose")], - reasoning_correlation_ids: vec![lash_core::TurnActivityId::new("failed-reasoning")], - })); - - assert!(app.timeline.is_empty()); - assert!(app.live.assistant.normalized_text().is_none()); - assert!(app.live.reasoning.normalized_text().is_none()); - assert_eq!(app.usage.live_output_tokens_estimate, 0); - - app.handle_turn_activity(TurnActivity::new( - lash_core::TurnActivityId::new("successful-prose"), - TurnEvent::AssistantProseDelta { - text: "final answer".into(), - }, - )); - assert_eq!( - app.live.assistant.normalized_text().as_deref(), - Some("final answer") - ); -} - -#[test] -fn text_delta_updates_live_token_estimate() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.handle_session_event(SessionStreamEvent::LlmRequest { - protocol_iteration: 0, - message_count: 0, - tool_list: String::new(), - }); - app.handle_session_event(SessionStreamEvent::TextDelta { - content: "abcd".into(), - }); - assert_eq!(app.usage.live_output_tokens_estimate, 1); - app.handle_session_event(SessionStreamEvent::TextDelta { - content: "efgh".into(), - }); - assert_eq!(app.usage.live_output_tokens_estimate, 2); -} - -#[test] -fn first_text_delta_switches_thinking_to_responding() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.handle_session_event(SessionStreamEvent::LlmRequest { - protocol_iteration: 0, - message_count: 0, - tool_list: String::new(), - }); - app.handle_session_event(SessionStreamEvent::TextDelta { - content: "hello".into(), - }); - assert_eq!( - app.live.turn.as_ref().map(|turn| turn.run_state), - Some(CliRunState::Responding) - ); - assert_eq!( - app.live - .turn - .as_ref() - .and_then(|turn| turn.status_detail.as_deref()), - None - ); -} - -#[test] -fn llm_request_sets_plain_thinking_status() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.handle_session_event(SessionStreamEvent::LlmRequest { - protocol_iteration: 0, - message_count: 0, - tool_list: String::new(), - }); - assert_eq!( - app.live.turn.as_ref().map(|turn| turn.run_state), - Some(CliRunState::Thinking) - ); - assert_eq!( - app.live - .turn - .as_ref() - .and_then(|turn| turn.status_detail.as_deref()), - None - ); -} - -#[test] -fn llm_request_flushes_intermediate_stream_text() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.handle_session_event(SessionStreamEvent::TextDelta { - content: "Let me continue testing.".into(), - }); - app.handle_session_event(SessionStreamEvent::ToolCall { - call_id: Some("tc1".into()), - name: "read_file".into(), - args: serde_json::json!({"path":"src/main.rs"}), - output: lash_core::ToolCallOutput::success(serde_json::json!("ok")), - duration_ms: 1, - }); - app.handle_session_event(SessionStreamEvent::LlmRequest { - protocol_iteration: 1, - message_count: 0, - tool_list: String::new(), - }); - - assert!(app.live.assistant.normalized_text().is_none()); - assert!(app.timeline.iter().any(|block| { - matches!(block, UiTimelineItem::AssistantText(text) if text == "Let me continue testing.") - })); -} - -#[test] -fn tool_call_flushes_intermediate_stream_text_immediately() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.timeline.truncate(0); - - app.handle_session_event(SessionStreamEvent::TextDelta { - content: "I’m checking the rendering path first.".into(), - }); - app.handle_session_event(SessionStreamEvent::ToolCall { - call_id: Some("tc2".into()), - name: "read_file".into(), - args: serde_json::json!({"path":"crates/lash-cli/src/app/mod.rs"}), - output: lash_core::ToolCallOutput::success(serde_json::json!("ok")), - duration_ms: 1, - }); - - assert!(app.live.assistant.normalized_text().is_none()); - assert!(matches!( - app.timeline.first(), - Some(UiTimelineItem::AssistantText(text)) - if text == "I’m checking the rendering path first." - )); - assert!(matches!( - app.timeline.get(1), - Some(UiTimelineItem::Activity(_)) - )); -} - -#[test] -fn tool_call_started_renders_running_activity_until_completion() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.timeline.truncate(0); - - app.handle_session_event(SessionStreamEvent::ToolCallStart { - call_id: Some("tc-running".into()), - name: "read_file".into(), - args: serde_json::json!({"path":"README.md"}), - }); - - assert_eq!(app.timeline.len(), 1); - match &app.timeline[0] { - UiTimelineItem::Activity(activity) => { - assert_eq!(activity.call.call_id.as_deref(), Some("tc-running")); - assert_eq!(activity.call.summary, "read README.md"); - assert_eq!(activity.result.status, ActivityStatus::Running); - } - other => panic!( - "expected running activity block, got {:?}", - other_variant_name(other) - ), - } - - app.handle_session_event(SessionStreamEvent::ToolCall { - call_id: Some("tc-running".into()), - name: "read_file".into(), - args: serde_json::json!({"path":"README.md"}), - output: lash_core::ToolCallOutput::success(serde_json::json!("==> README.md <==\n# Lash")), - duration_ms: 12, - }); - - assert_eq!(app.timeline.len(), 1); - match &app.timeline[0] { - UiTimelineItem::Activity(activity) => { - assert_eq!(activity.call.call_id.as_deref(), Some("tc-running")); - assert_eq!(activity.result.status, ActivityStatus::Completed); - assert_eq!(activity.duration_ms, 12); - } - other => panic!( - "expected completed activity block, got {:?}", - other_variant_name(other) - ), - } -} - -#[test] -fn token_usage_resets_live_token_estimate() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.handle_session_event(SessionStreamEvent::TextDelta { - content: "abcdefgh".into(), - }); - assert!(app.usage.live_output_tokens_estimate > 0); - app.handle_session_event(SessionStreamEvent::TokenUsage { - protocol_iteration: 0, - usage: TokenUsage { - input_tokens: 10, - output_tokens: 5, - cache_read_input_tokens: 0, - cache_write_input_tokens: 0, - reasoning_output_tokens: 2, - }, - cumulative: TokenUsage { - input_tokens: 10, - output_tokens: 5, - cache_read_input_tokens: 0, - cache_write_input_tokens: 0, - reasoning_output_tokens: 2, - }, - }); - assert_eq!(app.usage.live_output_tokens_estimate, 0); - assert_eq!(app.usage.last_response_usage.input_tokens, 10); - assert_eq!(app.usage.last_response_usage.reasoning_output_tokens, 2); -} - -#[test] -fn input_only_streamed_usage_keeps_live_output_estimate() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.handle_session_event(SessionStreamEvent::TextDelta { - content: "abcdefgh".into(), - }); - let live_estimate = app.usage.live_output_tokens_estimate; - assert!(live_estimate > 0); - app.handle_session_event(SessionStreamEvent::TokenUsage { - protocol_iteration: 0, - usage: TokenUsage { - input_tokens: 10, - output_tokens: 0, - cache_read_input_tokens: 0, - cache_write_input_tokens: 0, - reasoning_output_tokens: 0, - }, - cumulative: TokenUsage { - input_tokens: 10, - output_tokens: 0, - cache_read_input_tokens: 0, - cache_write_input_tokens: 0, - reasoning_output_tokens: 0, - }, - }); - assert_eq!(app.usage.live_output_tokens_estimate, live_estimate); - assert_eq!(app.usage.token_usage.input_tokens, 10); - assert_eq!(app.usage.last_response_usage.input_tokens, 10); -} - -#[test] -fn tool_output_renders_during_generic_running_turn() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.start_turn(); - app.handle_session_event(SessionStreamEvent::Message { - text: "started git status --short\n".into(), - kind: "tool_output".into(), - }); - - assert_eq!( - app.live.tool_output.lines, - vec!["started git status --short".to_string()] - ); -} - -#[test] -fn tool_output_carriage_return_rewrites_partial_line() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.start_turn(); - - app.handle_session_event(SessionStreamEvent::Message { - text: "Compiling alpha".into(), - kind: "tool_output".into(), - }); - assert_eq!(app.live.tool_output.partial, "Compiling alpha"); - - app.handle_session_event(SessionStreamEvent::Message { - text: "\rCompiling beta".into(), - kind: "tool_output".into(), - }); - - assert!(app.live.tool_output.lines.is_empty()); - assert_eq!(app.live.tool_output.partial, "Compiling beta"); -} - -#[test] -fn tool_output_crlf_commits_current_line() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.start_turn(); - - app.handle_session_event(SessionStreamEvent::Message { - text: "started cargo check\r\n".into(), - kind: "tool_output".into(), - }); - - assert_eq!( - app.live.tool_output.lines, - vec!["started cargo check".to_string()] - ); - assert!(app.live.tool_output.partial.is_empty()); -} - -#[test] -fn tool_output_strips_ansi_escape_sequences_from_live_preview() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.start_turn(); - - app.handle_session_event(SessionStreamEvent::Message { - text: "\u{1b}[33mwarning\u{1b}[0m: check this\n".into(), - kind: "tool_output".into(), - }); - - assert_eq!( - app.live.tool_output.lines, - vec!["warning: check this".to_string()] - ); - assert!(app.live.tool_output.partial.is_empty()); -} - -#[test] -fn tool_output_strips_osc_escape_sequences_from_live_preview() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.start_turn(); - - app.handle_session_event(SessionStreamEvent::Message { - text: "\u{1b}]11;?\u{1b}\\".into(), - kind: "tool_output".into(), - }); - app.handle_session_event(SessionStreamEvent::Message { - text: "done\n".into(), - kind: "tool_output".into(), - }); - - assert_eq!(app.live.tool_output.lines, vec!["done".to_string()]); - assert!(app.live.tool_output.partial.is_empty()); -} - -#[test] -fn tool_output_tabs_collapse_to_single_spaces_in_live_preview() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.start_turn(); - - app.handle_session_event(SessionStreamEvent::Message { - text: "hash\trefs/tags/v0.2.29\n".into(), - kind: "tool_output".into(), - }); - - assert_eq!( - app.live.tool_output.lines, - vec!["hash refs/tags/v0.2.29".to_string()] - ); - assert!(app.live.tool_output.partial.is_empty()); -} - -#[test] -fn tool_output_does_not_change_total_content_height() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.timeline = vec![UiTimelineItem::UserInput("inspect this".into())].into(); - app.start_turn(); - - let baseline = app.total_content_height(32, 8); - app.handle_session_event(SessionStreamEvent::Message { - text: "started git status --short\n".into(), - kind: "tool_output".into(), - }); - - assert_eq!(app.total_content_height(32, 8), baseline); -} - -#[test] -fn update_plan_panel_lights_up_plan_dock() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.start_turn(); - assert!( - app.plan_dock.is_none(), - "dock starts empty before any update_plan event" - ); - - app.handle_session_event(SessionStreamEvent::PluginEvent { - plugin_id: "update_plan".into(), - event: lash_core::PluginRuntimeEvent::Custom { - name: "update_plan.snapshot".into(), - payload: serde_json::json!({ - "generation": 1, - "plan": [ - {"step": "Inspect", "status": "completed"}, - {"step": "Patch layout", "status": "in_progress"}, - {"step": "Run tests", "status": "pending"} - ] - }), - }, - }); - - let dock = app.plan_dock.as_ref().expect("plan dock populated"); - assert_eq!(dock.title, "PLAN"); - assert_eq!(dock.items.len(), 3); - assert_eq!(dock.items[0].status, PlanDockItemStatus::Done); - assert_eq!(dock.items[1].status, PlanDockItemStatus::Active); - assert_eq!(dock.items[2].status, PlanDockItemStatus::Pending); - // Event must NOT land as an inline UiTimelineItem::PluginPanel. - assert!( - !app.timeline - .iter() - .any(|block| matches!(block, UiTimelineItem::PluginPanel(_))), - "update_plan panels route to the dock, not the scroll history" - ); -} - -#[test] -fn rlm_budget_warning_uses_status_not_user_message() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.timeline = vec![UiTimelineItem::AssistantText("working".into())].into(); - - app.handle_session_event(SessionStreamEvent::PluginEvent { - plugin_id: "rlm_protocol".into(), - event: lash_core::PluginRuntimeEvent::Status { - key: "rlm_context_budget_warning".into(), - label: "context budget".into(), - detail: Some("120292 tokens used; warn at 100000; choose frame switch path".into()), - }, - }); - - assert!( - app.timeline - .iter() - .all(|block| !matches!(block, UiTimelineItem::UserInput(_))), - "runtime budget warning must not be rendered as user input" - ); - assert!(app.live.turn.as_ref().is_some_and(|turn| { - turn.run_state == CliRunState::RunningTool - && turn.status_detail.as_deref().is_some_and(|detail| { - detail.contains("context budget") && detail.contains("choose frame switch path") - }) - })); -} - -#[test] -fn plan_protocol_state_events_upsert_and_clear_blocks() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.start_turn(); - app.handle_session_event(SessionStreamEvent::PluginEvent { - plugin_id: "plan_mode".into(), - event: lash_core::PluginRuntimeEvent::Custom { - name: "plan_mode.state".into(), - payload: serde_json::json!({ - "session_id": "test-session-id", - "enabled": true, - "plan_path": ".lash/plans/test-session-id.md" - }), - }, - }); - assert!(matches!( - app.timeline.last(), - Some(UiTimelineItem::PluginPanel(panel)) - if panel.title == "PLAN" - && panel.content.contains(".lash/plans/test-session-id.md") - )); - assert!( - app.live - .turn - .as_ref() - .is_some_and(|turn| turn.has_visible_output) - ); - - app.handle_session_event(SessionStreamEvent::PluginEvent { - plugin_id: "plan_mode".into(), - event: lash_core::PluginRuntimeEvent::Custom { - name: "plan_mode.state".into(), - payload: serde_json::json!({ - "session_id": "test-session-id", - "enabled": false, - "plan_path": null - }), - }, - }); - assert!( - !app.timeline - .iter() - .any(|block| matches!(block, UiTimelineItem::PluginPanel(_))) - ); -} - -#[test] -fn cancelled_error_renders_as_system_message() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.start_turn(); - app.note_manual_interrupt_requested(); - app.handle_session_event(SessionStreamEvent::Error { - message: "LLM error: cancelled".into(), - envelope: Some(lash_core::session_model::ErrorEnvelope { - kind: "llm_provider".into(), - code: Some("cancelled".into()), - terminal_reason: None, - user_message: "LLM error: cancelled".into(), - raw: None, - retryable: None, - provider_failure_kind: None, - }), - }); - - assert!(matches!( - app.timeline.last(), - Some(UiTimelineItem::SystemMessage(msg)) if msg == "Manually interrupted." - )); - assert_eq!(app.run_state, CliRunState::Idle); - assert!(app.live.turn.is_none()); -} - -#[test] -fn cancelled_error_without_manual_request_still_stops_immediately() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.start_turn(); - app.handle_session_event(SessionStreamEvent::Error { - message: "LLM error: cancelled".into(), - envelope: Some(lash_core::session_model::ErrorEnvelope { - kind: "llm_provider".into(), - code: Some("cancelled".into()), - terminal_reason: None, - user_message: "LLM error: cancelled".into(), - raw: None, - retryable: None, - provider_failure_kind: None, - }), - }); - - assert!(matches!( - app.timeline.last(), - Some(UiTimelineItem::SystemMessage(msg)) if msg == "Cancelled." - )); - assert_eq!(app.run_state, CliRunState::Idle); - assert!(app.live.turn.is_none()); -} - -#[test] -fn repeated_cancelled_errors_do_not_duplicate_system_message() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.start_turn(); - let cancelled = SessionStreamEvent::Error { - message: "LLM error: cancelled".into(), - envelope: Some(lash_core::session_model::ErrorEnvelope { - kind: "llm_provider".into(), - code: Some("cancelled".into()), - terminal_reason: None, - user_message: "LLM error: cancelled".into(), - raw: None, - retryable: None, - provider_failure_kind: None, - }), - }; - - app.handle_session_event(cancelled.clone()); - app.handle_session_event(cancelled); - - let cancelled_blocks = app - .timeline - .iter() - .filter(|block| { - matches!( - block, - UiTimelineItem::SystemMessage(msg) if msg == "Cancelled." - ) - }) - .count(); - assert_eq!(cancelled_blocks, 1); -} - -#[test] -fn keep_latest_user_block_visible_shows_prompt_start_before_first_token() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.timeline.truncate(0); - for idx in 0..6 { - app.timeline - .push(UiTimelineItem::AssistantText(format!("history {idx}"))); - } - app.timeline.push(UiTimelineItem::UserInput( - [ - "first line", - "second line", - "third line", - "fourth line", - "fifth line", - "sixth line", - ] - .join("\n"), - )); - app.start_turn(); - app.follow_mode = FollowOutputMode::PinnedTurnStart; - - let width = 32usize; - let viewport_height = 4usize; - app.ensure_height_cache_pub(width, viewport_height); - app.scroll_offset = usize::MAX; - - app.keep_latest_user_block_visible(); - - let last_idx = app.timeline.len() - 1; - let max_scroll = app - .total_content_height(width, viewport_height) - .saturating_sub(viewport_height); - let expected = - app.contextual_follow_offset(app.block_content_start_offset(last_idx), max_scroll); - assert_eq!(app.scroll_offset, expected); -} - -#[test] -fn keep_latest_user_block_visible_keeps_short_prompt_bottom_aligned() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.timeline.truncate(0); - for idx in 0..6 { - app.timeline - .push(UiTimelineItem::AssistantText(format!("history {idx}"))); - } - app.timeline - .push(UiTimelineItem::UserInput("short prompt".into())); - app.start_turn(); - app.follow_mode = FollowOutputMode::PinnedTurnStart; - - let width = 32usize; - let viewport_height = 4usize; - app.ensure_height_cache_pub(width, viewport_height); - app.scroll_offset = usize::MAX; - - app.keep_latest_user_block_visible(); - - let cache = app.height_cache_snapshot().to_vec(); - let last_idx = app.timeline.len() - 1; - let block_end = cache[last_idx]; - assert_eq!(app.scroll_offset, block_end.saturating_sub(viewport_height)); -} - -#[test] -fn empty_state_is_not_persisted_in_scrollback_once_history_exists() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.timeline - .push(UiTimelineItem::UserInput("short prompt".into())); - - let width = 32usize; - let viewport_height = 12usize; - app.ensure_height_cache_pub(width, viewport_height); - - let cache = app.height_cache_snapshot().to_vec(); - assert_eq!(app.timeline.len(), 1); - assert_eq!(cache[0], 1); -} - -#[test] -fn dismiss_splash_removes_empty_state_before_history_content() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.dismiss_splash(); - - assert!(app.timeline.is_empty()); - - app.timeline.push(UiTimelineItem::UserInput("hello".into())); - app.dismiss_splash(); - - assert_eq!(app.timeline.len(), 1); - assert!(matches!(app.timeline[0], UiTimelineItem::UserInput(_))); -} - -#[test] -fn refresh_scroll_position_tracks_bottom_when_idle() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.timeline.truncate(0); - - app.timeline - .push(UiTimelineItem::UserInput("Message 1".into())); - app.timeline.push(UiTimelineItem::AssistantText( - "Line 1\nLine 2\nLine 3\nLine 4\nLine 5".into(), - )); - - let width = 80; - app.follow_mode = FollowOutputMode::PinnedBottom; - - app.refresh_scroll_position(width, 3); - let small_bottom = app.total_content_height(width, 3).saturating_sub(3); - assert_eq!(app.scroll_offset, small_bottom); - - app.refresh_scroll_position(width, 6); - let large_bottom = app.total_content_height(width, 6).saturating_sub(6); - assert_eq!(app.scroll_offset, large_bottom); -} - -#[test] -fn refresh_scroll_position_reveals_output_start_once_then_follows_tail() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.timeline.truncate(0); - - app.timeline - .push(UiTimelineItem::UserInput("Message 1".into())); - app.timeline.push(UiTimelineItem::AssistantText( - "Line 1\nLine 2\nLine 3\nLine 4\nLine 5".into(), - )); - app.start_turn(); - app.follow_mode = FollowOutputMode::PinnedTurnStart; - if let Some(turn) = app.live.turn.as_mut() { - turn.has_visible_output = true; - turn.output_start_anchor_pending = true; - } - - let width = 80; - let viewport_height = 3; - - app.refresh_scroll_position(width, viewport_height); - let first_anchor = app.scroll_offset; - let max_scroll = app - .total_content_height(width, viewport_height) - .saturating_sub(viewport_height); - assert!(first_anchor < max_scroll); - assert_eq!(app.follow_mode, FollowOutputMode::PinnedBottom); - - app.refresh_scroll_position(width, viewport_height); - assert_eq!(app.scroll_offset, max_scroll); -} - -#[test] -fn resume_follow_output_reenables_bottom_following() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.timeline.truncate(0); - app.timeline.push(UiTimelineItem::UserInput("hello".into())); - app.timeline - .push(UiTimelineItem::AssistantText("world".into())); - app.follow_mode = FollowOutputMode::Manual; - app.scroll_offset = 3; - - app.resume_follow_output(); - - assert_eq!(app.follow_mode, FollowOutputMode::PinnedBottom); - assert_eq!(app.scroll_offset, usize::MAX); -} - -#[test] -fn scroll_up_from_follow_output_detaches_from_bottom_anchor() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.timeline.truncate(0); - app.timeline.push(UiTimelineItem::UserInput("hello".into())); - app.timeline.push(UiTimelineItem::AssistantText( - (0..20) - .map(|idx| format!("line {idx}")) - .collect::>() - .join("\n"), - )); - let width = 24; - let viewport_height = 5; - app.follow_mode = FollowOutputMode::PinnedBottom; - app.ensure_height_cache_pub(width, viewport_height); - app.refresh_scroll_position(width, viewport_height); - - let bottom = app.scroll_offset; - app.scroll_up(2); - - assert_eq!(app.follow_mode, FollowOutputMode::Manual); - assert_eq!(app.scroll_offset, bottom.saturating_sub(2)); -} - -#[test] -fn scroll_down_to_bottom_reenables_tail_follow_instead_of_contextual_anchor() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.timeline.truncate(0); - app.timeline - .push(UiTimelineItem::AssistantText("older history".into())); - app.timeline - .push(UiTimelineItem::UserInput("prompt".into())); - app.start_turn(); - app.follow_mode = FollowOutputMode::PinnedTurnStart; - - app.handle_session_event(SessionStreamEvent::TextDelta { - content: (0..20) - .map(|idx| format!("line {idx}")) - .collect::>() - .join("\n"), - }); - - let width = 24; - let viewport_height = 5; - app.refresh_scroll_position(width, viewport_height); - let contextual_anchor = app.scroll_offset; - - app.scroll_up(2); - assert_eq!(app.follow_mode, FollowOutputMode::Manual); - - app.scroll_down(usize::MAX / 2, viewport_height, width); - assert_eq!(app.follow_mode, FollowOutputMode::PinnedBottom); - - let max_scroll = app - .total_content_height(width, viewport_height) - .saturating_sub(viewport_height); - assert!( - contextual_anchor < max_scroll, - "test requires contextual anchor above the tail" - ); - - app.refresh_scroll_position(width, viewport_height); - - assert_eq!(app.scroll_offset, max_scroll); -} - -#[test] -fn text_delta_does_not_force_scroll_when_follow_output_is_manual() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.timeline.truncate(0); - app.timeline - .push(UiTimelineItem::UserInput("prompt".into())); - app.start_turn(); - app.follow_mode = FollowOutputMode::Manual; - app.scroll_offset = 3; - - app.handle_session_event(SessionStreamEvent::TextDelta { - content: "streamed output".into(), - }); - - assert_eq!(app.scroll_offset, 3); - assert_eq!(app.follow_mode, FollowOutputMode::Manual); -} - -#[test] -fn manual_scroll_keeps_explicit_offset_when_prior_block_height_changes() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.timeline.truncate(0); - app.timeline.push(UiTimelineItem::AssistantText( - (0..8) - .map(|idx| format!("old line {idx}")) - .collect::>() - .join("\n"), - )); - app.timeline.push(UiTimelineItem::AssistantText( - (0..8) - .map(|idx| format!("anchored line {idx}")) - .collect::>() - .join("\n"), - )); - - let width = 80; - let viewport_height = 4; - app.follow_mode = FollowOutputMode::PinnedBottom; - app.ensure_height_cache_pub(width, viewport_height); - app.refresh_scroll_position(width, viewport_height); - - let target = app.height_cache_snapshot()[0] + 2; - let bottom = app.scroll_offset; - app.scroll_up(bottom.saturating_sub(target)); - assert_eq!(app.follow_mode, FollowOutputMode::Manual); - assert_eq!(app.scroll_offset, target); - - if let Some(UiTimelineItem::AssistantText(text)) = app.timeline.iter_mut().next() { - text.insert_str(0, "inserted line 0\ninserted line 1\ninserted line 2\n"); - } - app.invalidate_height_cache_from(0); - app.refresh_scroll_position(width, viewport_height); - - assert_eq!(app.scroll_offset, target); - assert_eq!(app.follow_mode, FollowOutputMode::Manual); -} - -#[test] -fn text_delta_reveals_message_start_before_switching_to_tail_follow() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.timeline.truncate(0); - app.timeline - .push(UiTimelineItem::UserInput("prompt".into())); - app.start_turn(); - app.follow_mode = FollowOutputMode::PinnedTurnStart; - - app.handle_session_event(SessionStreamEvent::TextDelta { - content: (0..20) - .map(|idx| format!("line {idx}")) - .collect::>() - .join("\n"), - }); - - let width = 24; - let viewport_height = 5; - app.refresh_scroll_position(width, viewport_height); - - let max_scroll = app - .total_content_height(width, viewport_height) - .saturating_sub(viewport_height); - let expected = app.contextual_follow_offset( - app.latest_turn_output_start_offset() - .expect("live assistant start offset"), - max_scroll, - ); - - assert_eq!(app.scroll_offset, expected); - assert!( - app.scroll_offset < max_scroll, - "follow mode should anchor above the tail for tall streamed output" - ); - assert_eq!(app.follow_mode, FollowOutputMode::PinnedBottom); - - app.refresh_scroll_position(width, viewport_height); - assert_eq!(app.scroll_offset, max_scroll); -} - -#[test] -fn refresh_scroll_position_repositions_waiting_prompt_on_resize() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.timeline.push(UiTimelineItem::UserInput( - "A long prompt that should stay visible while we are waiting for first token output".into(), - )); - app.start_turn(); - app.follow_mode = FollowOutputMode::PinnedTurnStart; - - let width = 24; - app.refresh_scroll_position(width, 3); - let initial_offset = app.scroll_offset; - - app.refresh_scroll_position(width, 6); - assert!( - app.scroll_offset <= initial_offset, - "larger viewport should not push the waiting prompt further down" - ); -} - -#[test] -fn handle_tool_call_merges_contiguous_exploration_activity() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.timeline.truncate(0); - - app.handle_session_event(SessionStreamEvent::ToolCall { - call_id: Some("tc3".into()), - name: "grep".into(), - args: serde_json::json!({"query": "ctx"}), - output: lash_core::ToolCallOutput::success(serde_json::json!("match")), - duration_ms: 10, - }); - app.handle_session_event(SessionStreamEvent::ToolCall { - call_id: Some("tc4".into()), - name: "read_file".into(), - args: serde_json::json!({"path": "crates/lash-cli/src/render/mod.rs"}), - output: lash_core::ToolCallOutput::success(serde_json::json!( - "==> crates/lash-cli/src/render/mod.rs <==\nline" - )), - duration_ms: 5, - }); - - assert_eq!(app.timeline.len(), 1); - match &app.timeline[0] { - UiTimelineItem::Activity(activity) => { - assert_eq!(activity.call.kind, ActivityKind::Exploration); - // Multi-op explorations render as `Explored` + an op list. - // No tag, no step counter — the list is always visible at - // the default expand level so the count would duplicate it. - assert_eq!(activity.call.tag, None); - assert_eq!(activity.call.summary, "Explored"); - assert!(activity.children.is_empty()); - assert_eq!( - activity.result.detail_lines, - vec![ - "Search \"ctx\"".to_string(), - "Read crates/lash-cli/src/render/mod.rs".to_string(), - ] - ); - } - other => panic!( - "expected activity block, got {:?}", - other_variant_name(other) - ), - } -} - -#[test] -fn handle_tool_call_merges_contiguous_edit_activity() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.timeline.truncate(0); - - app.handle_session_event(SessionStreamEvent::ToolCall { - call_id: Some("tc5".into()), - name: "edit".into(), - args: serde_json::json!({"path": "a.rs"}), - output: lash_core::ToolCallOutput::success(serde_json::json!({ - "summary": "Successfully replaced 1 block(s) in a.rs.", - "path": "a.rs", - "replacements": 1, - "details": { - "patch": "--- a/a.rs\n+++ b/a.rs\n@@ -1,1 +1,1 @@\n-old\n+new", - "diff": "--- a/a.rs\n+++ b/a.rs\n@@ -1,1 +1,1 @@\n-old\n+new", - "firstChangedLine": 1 - } - })), - duration_ms: 7, - }); - app.handle_session_event(SessionStreamEvent::ToolCall { - call_id: Some("tc6".into()), - name: "edit".into(), - args: serde_json::json!({"path": "b.rs"}), - output: lash_core::ToolCallOutput::success(serde_json::json!({ - "summary": "Successfully replaced 1 block(s) in b.rs.", - "path": "b.rs", - "replacements": 1, - "details": { - "patch": "--- a/b.rs\n+++ b/b.rs\n@@ -0,0 +1,2 @@\n+fn one() {}\n+fn two() {}", - "diff": "--- a/b.rs\n+++ b/b.rs\n@@ -0,0 +1,2 @@\n+fn one() {}\n+fn two() {}", - "firstChangedLine": 1 - } - })), - duration_ms: 5, - }); - - assert_eq!(app.timeline.len(), 1); - match &app.timeline[0] { - UiTimelineItem::Activity(activity) => { - assert_eq!(activity.call.kind, ActivityKind::Edit); - assert_eq!(activity.call.summary, "Edited 2 files (+3 -1)"); - assert_eq!(activity.duration_ms, 12); - assert!(activity.children.is_empty()); - } - other => panic!( - "expected activity block, got {:?}", - other_variant_name(other) - ), - } -} - -#[test] -fn live_batch_tool_children_render_from_events_without_parent_batch_block() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.timeline.truncate(0); - - // The batch container renders nothing on its own. - app.handle_session_event(SessionStreamEvent::ToolCall { - call_id: Some("batch-0".into()), - name: "batch".into(), - args: serde_json::json!({ "tool_calls": [] }), - output: lash_core::ToolCallOutput::success(serde_json::json!({ "results": [] })), - duration_ms: 21, - }); - // Its children surface as their own tool events and render directly, with - // no batch arg/result re-parsing. - app.handle_session_event(SessionStreamEvent::ToolCall { - call_id: Some("batch-0:00".into()), - name: "read_file".into(), - args: serde_json::json!({ "path": "README.md" }), - output: lash_core::ToolCallOutput::success(serde_json::json!("README body")), - duration_ms: 8, - }); - app.handle_session_event(SessionStreamEvent::ToolCall { - call_id: Some("batch-0:01".into()), - name: "grep".into(), - args: serde_json::json!({ "query": "OpenAI", "path": "README.md" }), - output: lash_core::ToolCallOutput::success(serde_json::json!("match")), - duration_ms: 13, - }); - - let activities = app - .timeline - .iter() - .filter_map(|block| match block { - UiTimelineItem::Activity(activity) => Some(activity), - _ => None, - }) - .collect::>(); - assert_eq!(activities.len(), 1); - assert_eq!(activities[0].call.kind, ActivityKind::Exploration); - assert_eq!(activities[0].call.summary, "Explored"); - assert!( - activities - .iter() - .all(|activity| activity.call.tool_name != "batch" && activity.call.summary != "batch") - ); -} diff --git a/crates/lash-cli/src/app/tests/runtime_state.rs b/crates/lash-cli/src/app/tests/runtime_state.rs deleted file mode 100644 index cd50924..0000000 --- a/crates/lash-cli/src/app/tests/runtime_state.rs +++ /dev/null @@ -1,1353 +0,0 @@ -use super::*; - -#[test] -fn ui_extension_commands_appear_in_editor_suggestions() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let ui_extensions = lash_tui_extensions::TuiExtensions::builtin().expect("ui extensions"); - app.set_ui_extensions(Arc::new(ui_extensions)); - app.set_input("/pl".into()); - - app.update_suggestions(); - - assert!(app.suggestions().iter().any(|s| s.name == "/plan")); -} - -#[test] -fn ui_extension_argument_suggestions_complete_second_token() { - struct DemoTuiExtension; - - const DEMO_COMMANDS: &[SlashCommandSpec] = &[SlashCommandSpec { - name: "/demo", - aliases: &[], - usage: "/demo [help|off]", - description: "Demo command", - argument_hint: Some("[help|off]"), - argument_options: &["help", "off"], - takes_argument: true, - allow_while_running: true, - action: "demo", - }]; - - #[async_trait] - impl TuiExtension for DemoTuiExtension { - fn id(&self) -> &'static str { - "demo_ui" - } - - fn commands(&self) -> &'static [SlashCommandSpec] { - DEMO_COMMANDS - } - - async fn invoke_action( - &self, - _action: &str, - _arg: Option<&str>, - _ctx: TuiExtensionContext, - ) -> Result, String> { - Ok(Vec::new()) - } - } - - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let ui_extensions = - TuiExtensions::new(vec![Arc::new(DemoTuiExtension)]).expect("ui extensions"); - app.set_ui_extensions(Arc::new(ui_extensions)); - app.set_input("/demo h".into()); - app.editor.cursor_pos = app.input().len(); - - app.update_suggestions(); - - assert_eq!( - app.suggestions().first().map(|value| value.name.as_str()), - Some("help") - ); - app.complete_suggestion(); - - assert_eq!(app.input(), "/demo help"); -} - -#[test] -fn plan_exit_tool_does_not_create_local_shadow_queue() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let ui_extensions = - lash_tui_extensions::TuiExtensions::builtin().expect("builtin ui extensions"); - crate::ui_effects::apply_ui_host_effects( - &mut app, - ui_extensions.effects_for_turn_event(&TurnEvent::ToolCallCompleted { - call_id: Some("tc-plan-exit".into()), - name: "plan_exit".into(), - args: serde_json::json!({}), - graph_key: None, - parent_call_id: None, - output: lash_core::ToolCallOutput::success(serde_json::json!({ - "approved": true, - "confirmation_display": "Start implementing now\n\nNote: safe slice first", - "plan_path": ".lash/plans/session.md", - "execution_mode": "current_session", - "next_turn_input": "Execute the plan in `.lash/plans/session.md`." - })), - duration_ms: 5, - }), - ); - - assert!(app.pending_turn_input_snapshot().is_empty()); - assert!( - app.timeline - .iter() - .any(|block| matches!(block, UiTimelineItem::SystemMessage(message) if message.contains("live runtime queue"))) - ); -} - -#[test] -fn plan_exit_tool_call_consumes_pending_prompt_response() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let (tx, _rx) = std::sync::mpsc::channel(); - app.show_prompt(PromptState { - request: crate::prompt_model::PromptRequest::single( - "Review the plan", - vec!["Start implementing now".into(), "Keep planning".into()], - ) - .with_optional_note(), - focus: crate::overlay::PromptFocus::Text, - cursor: 0, - scroll_offset: 0, - selected: Default::default(), - reply_text: "safe slice first".into(), - reply_cursor: "safe slice first".len(), - response_tx: tx, - }); - - let response = app.take_prompt_response(); - assert_eq!( - response.as_deref(), - Some("1. Start implementing now\n\nNote: safe slice first") - ); - assert!( - app.timeline - .iter() - .all(|block| !matches!(block, UiTimelineItem::UserInput(_))) - ); - - app.handle_session_event(SessionStreamEvent::ToolCall { - call_id: Some("tc-plan-exit".into()), - name: "plan_exit".into(), - args: serde_json::json!({}), - output: lash_core::ToolCallOutput::success(serde_json::json!({ - "approved": true, - "confirmation_display": "Start implementing now\n\nNote: safe slice first", - "execution_mode": "current_session", - "next_turn_input": "Execute the plan in `.lash/plans/session.md`." - })), - duration_ms: 5, - }); - - assert!(app.timeline.iter().all(|block| { - !matches!(block, UiTimelineItem::UserInput(text) if text.contains("Start implementing now")) - })); -} - -#[test] -fn plan_exit_fresh_context_tool_does_not_queue_ui_turn_or_switch() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let ui_extensions = - lash_tui_extensions::TuiExtensions::builtin().expect("builtin ui extensions"); - crate::ui_effects::apply_ui_host_effects( - &mut app, - ui_extensions.effects_for_turn_event(&TurnEvent::ToolCallCompleted { - call_id: Some("tc-plan-exit-fresh".into()), - name: "plan_exit".into(), - args: serde_json::json!({}), - graph_key: None, - parent_call_id: None, - output: lash_core::ToolCallOutput::success(serde_json::json!({ - "approved": true, - "execution_mode": "fresh_context", - "session_id": "new-plan-session", - })), - duration_ms: 5, - }), - ); - - assert!(app.pending_turn_input_snapshot().is_empty()); - assert!( - app.timeline - .iter() - .all(|block| !matches!(block, UiTimelineItem::UserInput(_))), - "fresh-context switch is a runtime transition, not a UI-queued turn" - ); -} - -#[test] -fn non_manual_error_sets_transient_status() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.handle_session_event(SessionStreamEvent::LlmRequest { - protocol_iteration: 0, - message_count: 0, - tool_list: String::new(), - }); - app.handle_session_event(SessionStreamEvent::Error { - message: "LLM error: Claude request failed with 500".into(), - envelope: Some(lash_core::session_model::ErrorEnvelope { - kind: "llm_provider".into(), - code: Some("http_500".into()), - terminal_reason: None, - user_message: "LLM error: Claude request failed with 500".into(), - raw: None, - retryable: None, - provider_failure_kind: None, - }), - }); - app.handle_session_event(SessionStreamEvent::Done); - - assert_eq!( - app.live.turn.as_ref().map(|turn| turn.run_state), - Some(CliRunState::Error) - ); - assert_eq!( - app.live - .turn - .as_ref() - .and_then(|turn| turn.status_detail.as_deref()), - Some("LLM error: Claude request failed with 500") - ); -} - -#[test] -fn retry_status_stays_visible_when_retry_request_starts() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.handle_session_event(SessionStreamEvent::LlmRequest { - protocol_iteration: 0, - message_count: 0, - tool_list: String::new(), - }); - app.handle_session_event(SessionStreamEvent::RetryStatus { - wait_seconds: 2, - attempt: 2, - max_attempts: 4, - reason: "Codex returned non-SSE body but it could not be read".into(), - envelope: None, - }); - - assert_eq!( - app.live.turn.as_ref().map(|turn| turn.run_state), - Some(CliRunState::Waiting) - ); - assert_eq!( - app.live - .turn - .as_ref() - .and_then(|turn| turn.status_detail.as_deref()), - Some("in 2s · attempt 2/4 · Codex returned non-SSE body but it could not be read") - ); - - app.handle_session_event(SessionStreamEvent::LlmRequest { - protocol_iteration: 0, - message_count: 0, - tool_list: String::new(), - }); - - assert_eq!( - app.live.turn.as_ref().map(|turn| turn.run_state), - Some(CliRunState::Waiting) - ); - assert_eq!( - app.live - .turn - .as_ref() - .and_then(|turn| turn.status_detail.as_deref()), - Some("attempt 2/4 · Codex returned non-SSE body but it could not be read") - ); -} - -#[test] -fn transient_status_expires_on_tick() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.handle_session_event(SessionStreamEvent::LlmRequest { - protocol_iteration: 0, - message_count: 0, - tool_list: String::new(), - }); - app.handle_session_event(SessionStreamEvent::Error { - message: "runtime error".into(), - envelope: None, - }); - app.handle_session_event(SessionStreamEvent::Done); - - if let Some(turn) = app.live.turn.as_mut() { - turn.transient_until = Some(std::time::Instant::now() - std::time::Duration::from_secs(1)); - } - app.on_tick(); - assert!(app.live.turn.is_none()); -} - -#[test] -fn durable_queue_snapshot_preserves_order_and_cache_is_display_only() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let first = PreparedTurn::new("queued-1".into(), Vec::new()); - let second = PreparedTurn::new("queued-2".into(), Vec::new()); - app.test_seed_queued_turn_snapshot(first.clone(), lash_core::TurnInputIngress::NextTurn); - app.test_seed_queued_turn_snapshot(second.clone(), lash_core::TurnInputIngress::NextTurn); - - let previews = app - .pending_turn_input_snapshot() - .iter() - .filter_map(|input| app.prepared_turn_for_pending_input(input)) - .map(|turn| turn.display_text) - .collect::>(); - - assert_eq!(previews, vec!["queued-1", "queued-2"]); - assert_eq!(app.queues.draft_presentations.len(), 2); -} - -#[test] -fn durable_queue_snapshot_filters_completed_inputs_from_preview_state() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let visible = PreparedTurn::new("visible turn".into(), Vec::new()); - let hidden = PreparedTurn::new("already completed".into(), Vec::new()); - let visible_id = - app.test_seed_queued_turn_snapshot(visible, lash_core::TurnInputIngress::NextTurn); - let hidden_id = - app.test_seed_queued_turn_snapshot(hidden, lash_core::TurnInputIngress::NextTurn); - let mut snapshot = app.pending_turn_input_snapshot().to_vec(); - for input in &mut snapshot { - if input.input_id == hidden_id { - input.state = lash_core::TurnInputState::Completed; - } - } - app.set_pending_turn_input_snapshot(snapshot); - - assert_eq!( - app.pending_turn_input_snapshot() - .iter() - .filter(|input| !matches!(input.state, lash_core::TurnInputState::Completed)) - .map(|input| input.input_id.as_str()) - .collect::>(), - vec![visible_id.as_str()] - ); - assert!(app.has_queued_messages()); -} - -#[test] -fn pending_turn_input_removal_drops_claimed_input_from_preview() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let first_id = app.test_seed_queued_turn_snapshot( - PreparedTurn::new("processing now".into(), Vec::new()), - lash_core::TurnInputIngress::NextTurn, - ); - let second_id = app.test_seed_queued_turn_snapshot( - PreparedTurn::new("still pending".into(), Vec::new()), - lash_core::TurnInputIngress::NextTurn, - ); - - app.remove_pending_turn_inputs(std::slice::from_ref(&first_id)); - - assert_eq!( - app.pending_turn_input_snapshot() - .iter() - .map(|input| input.input_id.as_str()) - .collect::>(), - vec![second_id.as_str()] - ); -} - -#[test] -fn take_prepared_turn_for_pending_input_restores_cached_images() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let turn = PreparedTurn::new("queued".into(), vec![vec![1, 2, 3]]); - app.test_seed_queued_turn_snapshot(turn, lash_core::TurnInputIngress::NextTurn); - - let pending = app.pending_turn_input_snapshot()[0].clone(); - let restored = app - .take_prepared_turn_for_pending_input(&pending) - .expect("queued presentation"); - - assert_eq!(restored.display_text, "queued"); - assert_eq!(restored.images.len(), 1); - assert_eq!(restored.images[0].id, 1); - assert_eq!(restored.images[0].png_bytes, vec![1, 2, 3]); - assert!(app.queues.draft_presentations.is_empty()); -} - -#[test] -fn accepted_injected_turn_input_renders_matching_draft_presentation() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let turn = PreparedTurn::new("follow up".into(), Vec::new()); - app.cache_draft_presentation(turn.clone()); - - app.handle_session_event(SessionStreamEvent::InjectedTurnInputAccepted { - inputs: vec![lash_core::AcceptedInjectedTurnInput { - id: None, - message: PluginMessage::text(MessageRole::User, "follow up"), - }], - checkpoint: lash_core::CheckpointKind::AfterWork, - }); - - assert!(app.queues.draft_presentations.is_empty()); - assert!( - app.timeline - .iter() - .any(|block| matches!(block, UiTimelineItem::UserInput(text) if text == "follow up")) - ); -} - -#[test] -fn accepted_injected_turn_input_matches_by_runtime_content_even_when_display_text_differs() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let mut turn = PreparedTurn::new("/localref lash for context if needed".into(), Vec::new()); - turn.effective_text = - "/localref lash for context if needed\n\n\nlocalref\nbody\n" - .into(); - turn.input_metadata.effective_text = turn.effective_text.clone(); - app.cache_draft_presentation(turn.clone()); - - app.handle_session_event(SessionStreamEvent::InjectedTurnInputAccepted { - inputs: vec![lash_core::AcceptedInjectedTurnInput { - id: None, - message: PluginMessage::text( - MessageRole::User, - "/localref lash for context if needed\n\n\nlocalref\nbody\n", - ), - }], - checkpoint: lash_core::CheckpointKind::AfterWork, - }); - - assert!(app.queues.draft_presentations.is_empty()); - assert!(app.timeline.iter().any(|block| matches!( - block, - UiTimelineItem::UserInput(text) if text == "/localref lash for context if needed" - ))); -} - -#[test] -fn accepted_injected_turn_input_prefers_draft_id_over_content_match() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let first = PreparedTurn::new("first queued steer".into(), Vec::new()); - let accepted = PreparedTurn::new("visible accepted draft".into(), Vec::new()); - let accepted_id = accepted.draft_id.clone(); - app.cache_draft_presentation(first.clone()); - app.cache_draft_presentation(accepted.clone()); - - app.handle_session_event(SessionStreamEvent::InjectedTurnInputAccepted { - inputs: vec![lash_core::AcceptedInjectedTurnInput { - id: Some(accepted_id), - message: PluginMessage::text(MessageRole::User, "runtime accepted content"), - }], - checkpoint: lash_core::CheckpointKind::AfterWork, - }); - - assert_eq!(app.queues.draft_presentations.len(), 1); - assert!(app.queues.draft_presentations.contains_key(&first.draft_id)); - assert!(app.timeline.iter().any(|block| matches!( - block, - UiTimelineItem::UserInput(text) if text == "visible accepted draft" - ))); - assert!(!app.timeline.iter().any(|block| matches!( - block, - UiTimelineItem::UserInput(text) if text == "runtime accepted content" - ))); -} - -#[test] -fn live_turn_requires_visible_user_input_before_enter_injection() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - - app.start_turn(); - - assert!(!app.can_inject_into_active_turn()); - assert_eq!( - app.route_turn_submission(true), - TurnSubmissionRoute::QueueNextFullTurn - ); - - app.mark_live_turn_user_input_visible(); - - assert!(app.can_inject_into_active_turn()); - assert_eq!( - app.route_turn_submission(true), - TurnSubmissionRoute::InjectActiveTurn - ); -} - -#[test] -fn turn_submission_route_sends_immediately_when_idle() { - let app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - - assert_eq!( - app.route_turn_submission(true), - TurnSubmissionRoute::SendNow - ); -} - -#[test] -fn turn_submission_route_blocks_when_runtime_is_switching() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - - assert_eq!( - app.route_turn_submission(false), - TurnSubmissionRoute::BlockedSessionSwitch - ); - - app.start_turn(); - app.mark_live_turn_user_input_visible(); - - assert_eq!( - app.route_turn_submission(false), - TurnSubmissionRoute::BlockedSessionSwitch - ); -} - -#[test] -fn accepted_injected_turn_input_without_pending_match_still_renders_once() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - - app.handle_session_event(SessionStreamEvent::InjectedTurnInputAccepted { - inputs: vec![lash_core::AcceptedInjectedTurnInput { - id: None, - message: PluginMessage { - id: None, - role: MessageRole::User, - content: "runtime content".into(), - origin: None, - parts: Vec::new(), - images: Vec::new(), - }, - }], - checkpoint: lash_core::CheckpointKind::AfterWork, - }); - - app.handle_session_event(SessionStreamEvent::InjectedMessagesCommitted { - messages: vec![PluginMessage { - id: None, - role: MessageRole::User, - content: "runtime content".into(), - origin: None, - parts: Vec::new(), - images: Vec::new(), - }], - checkpoint: lash_core::CheckpointKind::AfterWork, - }); - - let matching_blocks = app - .timeline - .iter() - .filter( - |block| matches!(block, UiTimelineItem::UserInput(text) if text == "runtime content"), - ) - .count(); - assert_eq!(matching_blocks, 1); -} - -#[test] -fn accepted_injected_turn_input_removes_matching_draft_without_popping_wrong_one() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let first = PreparedTurn::new("first queued steer".into(), Vec::new()); - app.cache_draft_presentation(first.clone()); - app.cache_draft_presentation(PreparedTurn::new( - "uhh do not switch nvm".into(), - Vec::new(), - )); - - app.handle_session_event(SessionStreamEvent::InjectedTurnInputAccepted { - inputs: vec![lash_core::AcceptedInjectedTurnInput { - id: None, - message: PluginMessage::text(MessageRole::User, "uhh do not switch nvm"), - }], - checkpoint: lash_core::CheckpointKind::AfterWork, - }); - - assert_eq!(app.queues.draft_presentations.len(), 1); - assert!(app.queues.draft_presentations.contains_key(&first.draft_id)); -} - -#[test] -fn injected_messages_committed_do_not_duplicate_user_input_after_assistant_work() { - // Regression: when the runtime fires `InjectedMessagesCommitted` after the - // assistant has already streamed text and tool calls (codex prose-between- - // tool-calls flow), the existing UserInput block is no longer at the - // tail of `app.timeline` — so the old `blocks.last()` dedup let a duplicate - // through. Dedup must scan the whole current turn. - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let turn = PreparedTurn::new("Why are you still dillydallying".into(), Vec::new()); - app.push_prepared_user_input(&turn); - app.cache_draft_presentation(turn.clone()); - - // Assistant streams some prose, then runs a tool — this pushes an - // AssistantText and an Activity block in front of the original UserInput. - app.handle_session_event(SessionStreamEvent::TextDelta { - content: "You're right.".into(), - }); - app.handle_session_event(SessionStreamEvent::ToolCall { - name: "fs_read".into(), - args: serde_json::json!({"path": "crates/lash/src/plugin.rs"}), - output: lash_core::ToolCallOutput::success(serde_json::json!({"output": "..."})), - duration_ms: 12, - call_id: None, - }); - - // Now the late commit arrives. - app.handle_session_event(SessionStreamEvent::InjectedMessagesCommitted { - messages: vec![PluginMessage::text( - MessageRole::User, - "Why are you still dillydallying", - )], - checkpoint: lash_core::CheckpointKind::AfterWork, - }); - - let matching_blocks = app - .timeline - .iter() - .filter(|block| { - matches!( - block, - UiTimelineItem::UserInput(text) if text == "Why are you still dillydallying" - ) - }) - .count(); - assert_eq!(matching_blocks, 1); -} - -#[test] -fn injected_messages_committed_do_not_duplicate_existing_visible_user_input() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let turn = PreparedTurn::new( - "(I want future migrations to work though!)".into(), - Vec::new(), - ); - app.push_prepared_user_input(&turn); - app.cache_draft_presentation(turn.clone()); - - app.handle_session_event(SessionStreamEvent::InjectedMessagesCommitted { - messages: vec![PluginMessage::text( - MessageRole::User, - "(I want future migrations to work though!)", - )], - checkpoint: lash_core::CheckpointKind::AfterWork, - }); - - let matching_blocks = app - .timeline - .iter() - .filter(|block| { - matches!( - block, - UiTimelineItem::UserInput(text) - if text == "(I want future migrations to work though!)" - ) - }) - .count(); - assert_eq!(matching_blocks, 1); - assert!(app.queues.draft_presentations.is_empty()); -} - -#[test] -fn queued_injection_stays_out_of_history_until_committed() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let turn = PreparedTurn::new("follow up now".into(), Vec::new()); - - app.test_seed_queued_turn_snapshot(turn, lash_core::TurnInputIngress::NextTurn); - - assert_eq!(app.pending_turn_input_snapshot().len(), 1); - assert!(!matches!( - app.timeline.last(), - Some(UiTimelineItem::UserInput(_)) - )); -} - -#[test] -fn idle_earliest_boundary_preview_is_next_full_turn_not_this_turn() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.test_seed_queued_turn_snapshot( - PreparedTurn::new("follow up now".into(), Vec::new()), - lash_core::TurnInputIngress::NextTurn, - ); - - let preview = crate::render::queue_preview_lines_snapshot(&app, 80) - .into_iter() - .map(|line| { - line.spans - .into_iter() - .map(|span| span.content.into_owned()) - .collect::() - }) - .collect::>() - .join("\n"); - - assert!(!preview.contains("Will send in this turn")); - assert!(preview.contains("Queued for next turn")); - assert!(preview.contains("follow up now")); -} - -#[test] -fn regular_queued_turn_stays_out_of_history_until_dispatched() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let turn = PreparedTurn::new("queued text".into(), Vec::new()); - app.test_seed_queued_turn_snapshot(turn, lash_core::TurnInputIngress::NextTurn); - - assert_eq!(app.pending_turn_input_snapshot().len(), 1); - assert!(!matches!( - app.timeline.last(), - Some(UiTimelineItem::UserInput(_)) - )); -} - -#[test] -fn suppressed_ready_batch_does_not_render_queue_preview() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let batch_id = app.test_seed_queued_turn_snapshot( - PreparedTurn::new("send immediately".into(), Vec::new()), - lash_core::TurnInputIngress::NextTurn, - ); - - assert!( - crate::render::queue_preview_lines_snapshot(&app, 80) - .iter() - .any(|line| line - .spans - .iter() - .any(|span| span.content.contains("send immediately"))) - ); - - app.suppress_queue_preview_inputs([batch_id.as_str()]); - - assert!(crate::render::queue_preview_lines_snapshot(&app, 80).is_empty()); - assert!(!app.has_queued_messages()); -} - -#[test] -fn restore_prepared_turn_clears_history_selection() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.editor.input_history = vec!["older turn".into()]; - app.editor.input_history_idx = Some(0); - app.start_input_selection(1); - app.update_input_selection(4); - app.finish_input_selection(); - - app.restore_prepared_turn(PreparedTurn::new("queued text".into(), Vec::new())); - - assert_eq!(app.editor.input_history_idx, None); - assert!(!app.has_input_selection()); -} - -#[test] -fn text_selection_contract_tracks_output_and_input_selection() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.selection.anchor = (1, 0); - app.selection.end = (4, 0); - app.selection.visible = true; - app.set_input("input selection".into()); - app.start_input_selection(0); - app.update_input_selection(5); - app.finish_input_selection(); - - assert!(app.has_output_selection()); - assert!(app.has_visible_output_selection()); - assert!(app.has_text_selection()); - assert!(app.has_visible_text_selection()); - - app.clear_text_selection(); - - assert!(!app.has_output_selection()); - assert!(!app.has_input_selection()); - assert!(app.dirty); -} - -#[test] -fn selection_mouse_up_suppression_is_one_shot() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - - assert!(!app.take_suppressed_mouse_up_after_selection_clear()); - app.suppress_mouse_up_after_selection_clear(); - - assert!(app.take_suppressed_mouse_up_after_selection_clear()); - assert!(!app.take_suppressed_mouse_up_after_selection_clear()); -} - -#[test] -fn toast_expires_on_tick() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.show_toast("Copied to clipboard", ToastKind::Info); - app.toast.as_mut().expect("toast").expires_at = - std::time::Instant::now() - std::time::Duration::from_secs(1); - - app.on_tick(); - - assert!(app.toast.is_none()); - assert!(app.dirty); -} - -#[test] -fn backspace_deletes_image_marker_atomically() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.set_input("hello [Image #2] world".into()); - app.editor.cursor_pos = "hello [Image #2]".len(); - app.editor.pending_images = vec![PendingImage { - id: 2, - png_bytes: vec![1, 2, 3], - }]; - - app.editor.backspace(); - - assert_eq!(app.input(), "hello world"); - assert!(app.editor.pending_images.is_empty()); - assert_eq!(app.cursor_pos(), "hello ".len()); -} - -#[test] -fn next_image_marker_id_tracks_highest_visible_marker() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.set_input("[Image #2] [Image #5]".into()); - app.editor.pending_images = vec![PendingImage { - id: 2, - png_bytes: vec![1, 2, 3], - }]; - - assert_eq!(app.next_image_marker_id(), 6); -} - -#[test] -fn add_pending_image_uses_highest_marker_plus_one() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.set_input("before [Image #4] after".into()); - app.editor.pending_images = vec![PendingImage { - id: 2, - png_bytes: vec![9], - }]; - - let id = app.add_pending_image(vec![1, 2, 3]); - - assert_eq!(id, 5); - assert_eq!(app.editor.pending_images.last().map(|img| img.id), Some(5)); -} - -#[test] -fn complete_pending_image_only_attaches_when_marker_still_exists() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.set_input("before [Image #3] after".into()); - app.begin_pending_image(3); - - assert!(app.complete_pending_image(3, vec![1, 2, 3])); - assert_eq!(app.editor.pending_images.len(), 1); - assert_eq!(app.editor.pending_images[0].id, 3); - - app.editor.input.clear(); - app.begin_pending_image(4); - assert!(!app.complete_pending_image(4, vec![9])); - assert!(app.editor.pending_images.iter().all(|image| image.id != 4)); -} - -#[test] -fn fail_pending_image_removes_marker_and_inflight_state() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.set_input("before [Image #7] after".into()); - app.editor.cursor_pos = app.input().len(); - app.begin_pending_image(7); - - assert!(app.fail_pending_image(7)); - assert_eq!(app.input(), "before after"); - assert!(!app.editor.inflight_image_ids.contains(&7)); -} - -#[test] -fn pending_image_jobs_only_count_visible_markers() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.begin_pending_image(2); - assert!(!app.has_pending_image_jobs()); - - app.set_input("[Image #2]".into()); - app.editor.cursor_pos = app.input().len(); - assert!(app.has_pending_image_jobs()); - - app.editor.backspace(); - assert!(!app.has_pending_image_jobs()); -} - -#[test] -fn try_take_prepared_turn_waits_for_visible_inflight_images() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.set_input("[Image #1]".into()); - app.begin_pending_image(1); - - assert!(app.try_take_prepared_turn().is_none()); - assert_eq!(app.input(), "[Image #1]"); - assert!(app.has_pending_image_jobs()); -} - -#[test] -fn take_prompt_response_renders_visible_user_block() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let (tx, rx) = std::sync::mpsc::channel(); - app.show_prompt(PromptState { - request: crate::prompt_model::PromptRequest::freeform("Pick one"), - focus: crate::overlay::PromptFocus::Text, - cursor: 0, - scroll_offset: 0, - selected: Default::default(), - reply_text: "red".into(), - reply_cursor: 3, - response_tx: tx, - }); - - let response = app.take_prompt_response(); - - assert_eq!(response.as_deref(), Some("red")); - assert_eq!( - rx.recv().expect("response"), - crate::prompt_model::PromptResponse::Text { - text: "red".to_string(), - } - ); - assert!(app.prompt_state().is_none()); - assert!(app.dirty); - assert!(matches!( - app.timeline.last(), - Some(UiTimelineItem::UserInput(text)) if text == "red" - )); -} - -#[test] -fn dismiss_prompt_marks_ui_dirty() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let (tx, rx) = std::sync::mpsc::channel(); - app.show_prompt(PromptState { - request: crate::prompt_model::PromptRequest::single("Pick one", vec!["red".into()]), - focus: crate::overlay::PromptFocus::Options, - cursor: 0, - scroll_offset: 0, - selected: Default::default(), - reply_text: String::new(), - reply_cursor: 0, - response_tx: tx, - }); - - app.dismiss_prompt(); - - assert_eq!( - rx.recv().expect("response"), - crate::prompt_model::PromptResponse::Single { - selection: String::new(), - note: None, - } - ); - assert!(app.prompt_state().is_none()); - assert!(app.dirty); -} - -#[test] -fn insert_text_inserts_literal_payload_at_cursor() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.set_input("startend".into()); - app.editor.cursor_pos = "start".len(); - - app.insert_text("\nplain pasted text\n"); - - assert_eq!(app.input(), "start\nplain pasted text\nend"); - assert_eq!(app.cursor_pos(), "start\nplain pasted text\n".len()); -} - -#[test] -fn insert_pasted_text_large_uses_placeholder_and_prepare_expands() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); - - app.insert_pasted_text(&large); - - let placeholder = format!("[Pasted Content {} chars]", large.chars().count()); - assert_eq!(app.input(), placeholder); - assert_eq!(app.editor.pending_large_pastes.len(), 1); - - let prepared = app.take_prepared_turn(); - assert_eq!(prepared.display_text, placeholder); - assert_eq!(prepared.effective_text, large); - assert_eq!(prepared.large_pastes.len(), 1); - assert!(app.input().is_empty()); - assert!(app.editor.pending_large_pastes.is_empty()); -} - -#[test] -fn backspace_deletes_large_paste_placeholder_atomically() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 2); - - app.insert_pasted_text(&large); - let placeholder = app.input().to_string(); - app.editor.cursor_pos = placeholder.len(); - - app.editor.backspace(); - - assert!(app.input().is_empty()); - assert!(app.editor.pending_large_pastes.is_empty()); -} - -#[test] -fn repeated_same_size_large_pastes_get_numbered_placeholders() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4); - let base = format!("[Pasted Content {} chars]", large.chars().count()); - - app.insert_pasted_text(&large); - app.insert_pasted_text(&large); - - assert_eq!(app.input(), format!("{base}{base} #2")); - assert_eq!(app.editor.pending_large_pastes.len(), 2); - assert_eq!(app.editor.pending_large_pastes[0].placeholder, base); - assert_eq!( - app.editor.pending_large_pastes[1].placeholder, - format!("{base} #2") - ); -} - -#[test] -fn prepared_turn_history_text_annotates_only_pasted_content_inline() { - let large = format!("alpha {}\nomega", "x".repeat(480)); - let char_count = large.chars().count(); - let placeholder = format!("[Pasted Content {char_count} chars]"); - let turn = PreparedTurn::prepare_with_large_pastes( - format!("before {placeholder} after"), - Vec::new(), - &SkillCatalog::default(), - vec![LargePaste { - placeholder: placeholder.clone(), - content: large, - }], - ); - - let history = turn.history_text(); - assert!(history.starts_with("before ")); - assert!(history.ends_with(" after")); - assert!(history.contains(&placeholder)); - assert!(!history.contains('\n')); -} - -#[test] -fn prepared_turn_display_text_keeps_same_large_paste_placeholder_as_input() { - let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 9); - let placeholder = format!("[Pasted Content {} chars]", large.chars().count()); - - let turn = PreparedTurn::prepare_with_large_pastes( - format!("before {placeholder} after"), - Vec::new(), - &SkillCatalog::default(), - vec![LargePaste { - placeholder: placeholder.clone(), - content: large.clone(), - }], - ); - - assert_eq!(turn.display_text, format!("before {placeholder} after")); - assert_eq!(turn.history_text(), format!("before {placeholder} after")); - assert_eq!(turn.effective_text, format!("before {large} after")); -} - -#[test] -fn prepared_turn_history_text_keeps_long_user_text_without_middle_truncation() { - let text = format!( - "I would like to add some kind of all knowing knowledge graph to figments. {} user facing documentation.", - "x".repeat(400) - ); - let turn = PreparedTurn::prepare(text.clone(), Vec::new(), &SkillCatalog::default()); - - let history = turn.history_text(); - assert_eq!(history, text); - assert!(!history.contains("chars hidden")); -} - -#[test] -fn prompt_insert_text_inserts_literal_payload_at_cursor() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.show_prompt(PromptState { - request: crate::prompt_model::PromptRequest::freeform("Question?"), - focus: crate::overlay::PromptFocus::Text, - cursor: 0, - scroll_offset: 0, - selected: Default::default(), - reply_text: "startend".into(), - reply_cursor: "start".len(), - response_tx: std::sync::mpsc::channel().0, - }); - - app.prompt_insert_text("\nplain pasted text\n"); - - let prompt = app.prompt_state().expect("prompt"); - assert_eq!(prompt.reply_text, "start\nplain pasted text\nend"); - assert_eq!(prompt.reply_cursor, "start\nplain pasted text\n".len()); -} - -#[test] -fn prompt_toggle_current_option_ignores_freeform_prompts() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.show_prompt(PromptState { - request: crate::prompt_model::PromptRequest::freeform("Question?"), - focus: crate::overlay::PromptFocus::Text, - cursor: 0, - scroll_offset: 0, - selected: Default::default(), - reply_text: String::new(), - reply_cursor: 0, - response_tx: std::sync::mpsc::channel().0, - }); - - app.prompt_toggle_current_option(); - - assert!( - app.prompt_state().expect("prompt").is_freeform(), - "freeform prompts should stay in text-entry mode" - ); -} - -#[test] -fn prompt_toggle_note_focus_switches_between_choices_and_note() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.show_prompt(PromptState { - request: crate::prompt_model::PromptRequest::single( - "Pick one", - vec!["red".into(), "blue".into()], - ) - .with_optional_note(), - focus: crate::overlay::PromptFocus::Options, - cursor: 0, - scroll_offset: 0, - selected: Default::default(), - reply_text: String::new(), - reply_cursor: 0, - response_tx: std::sync::mpsc::channel().0, - }); - - assert!(app.prompt_supports_note()); - assert!(!app.is_prompt_text_entry()); - - app.prompt_toggle_note_focus(); - assert!(app.is_prompt_text_entry()); - - app.prompt_toggle_note_focus(); - assert!(!app.is_prompt_text_entry()); -} - -#[test] -fn take_prompt_response_defers_option_prompt_display() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let (tx, rx) = std::sync::mpsc::channel(); - app.show_prompt(PromptState { - request: crate::prompt_model::PromptRequest::single( - "Pick one", - vec!["red".into(), "blue".into()], - ) - .with_optional_note(), - focus: crate::overlay::PromptFocus::Text, - cursor: 1, - scroll_offset: 0, - selected: Default::default(), - reply_text: "ship the blue path".into(), - reply_cursor: "ship the blue path".len(), - response_tx: tx, - }); - - let response = app.take_prompt_response(); - - assert_eq!( - response.as_deref(), - Some("2. blue\n\nNote: ship the blue path") - ); - assert_eq!( - rx.recv().expect("response"), - crate::prompt_model::PromptResponse::Single { - selection: "blue".to_string(), - note: Some("ship the blue path".to_string()), - } - ); - assert!( - !app.timeline - .iter() - .any(|block| matches!(block, UiTimelineItem::UserInput(_))) - ); -} - -#[test] -fn option_prompt_response_falls_back_to_user_block_without_inline_panel() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let (tx, _rx) = std::sync::mpsc::channel(); - app.show_prompt(PromptState { - request: crate::prompt_model::PromptRequest::single( - "Pick one", - vec!["red".into(), "blue".into()], - ), - focus: crate::overlay::PromptFocus::Options, - cursor: 0, - scroll_offset: 0, - selected: Default::default(), - reply_text: String::new(), - reply_cursor: 0, - response_tx: tx, - }); - - let response = app.take_prompt_response(); - assert_eq!(response.as_deref(), Some("1. red")); - - app.handle_session_event(SessionStreamEvent::ToolCall { - call_id: None, - name: "search_tools".into(), - args: serde_json::json!({ "query": "queue" }), - output: lash_core::ToolCallOutput::success(serde_json::json!([])), - duration_ms: 1, - }); - - assert!( - app.timeline - .iter() - .any(|block| matches!(block, UiTimelineItem::UserInput(text) if text == "1. red")) - ); -} - -#[test] -fn option_prompt_response_is_rendered_inline_by_question_panel_artifact() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let (tx, _rx) = std::sync::mpsc::channel(); - app.show_prompt(PromptState { - request: crate::prompt_model::PromptRequest::single( - "Pick one", - vec!["red".into(), "blue".into()], - ) - .with_optional_note(), - focus: crate::overlay::PromptFocus::Text, - cursor: 1, - scroll_offset: 0, - selected: Default::default(), - reply_text: "ship the blue path".into(), - reply_cursor: "ship the blue path".len(), - response_tx: tx, - }); - - let response = app.take_prompt_response(); - assert_eq!( - response.as_deref(), - Some("2. blue\n\nNote: ship the blue path") - ); - - app.handle_session_event(SessionStreamEvent::ToolCall { - call_id: None, - name: "ask".into(), - args: serde_json::json!({ - "question": "Pick one", - "options": ["red", "blue"] - }), - output: lash_core::ToolCallOutput::success(serde_json::json!({ - "kind": "single", - "selection": "blue", - "note": "ship the blue path" - })), - duration_ms: 1, - }); - - assert!( - !app.timeline - .iter() - .any(|block| matches!(block, UiTimelineItem::UserInput(_))) - ); - assert!(matches!( - app.timeline.last(), - Some(UiTimelineItem::Activity(activity)) - if matches!( - activity.result.artifact.as_ref(), - Some(ActivityArtifact::QuestionPanel(panel)) - if panel.options.len() == 2 - && !panel.options[0].selected - && panel.options[1].selected - && panel.note.as_deref() == Some("ship the blue path") - ) - )); -} - -#[test] -fn at_completion_finds_nested_file_via_fuzzy() { - use std::fs; - use tempfile::TempDir; - - let dir = TempDir::new().expect("tempdir"); - fs::create_dir_all(dir.path().join("crates/lash-cli/src/interactive")).unwrap(); - fs::create_dir_all(dir.path().join("crates/lash-cli/src")).unwrap(); - fs::create_dir_all(dir.path().join("other/dir")).unwrap(); - fs::write( - dir.path() - .join("crates/lash-cli/src/interactive/input_handling.rs"), - "", - ) - .unwrap(); - fs::write(dir.path().join("crates/lash-cli/src/editor.rs"), "").unwrap(); - fs::write(dir.path().join("other/dir/unrelated.rs"), "").unwrap(); - - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let index = lash_file_index::FileIndex::for_root_blocking(dir.path().to_path_buf()); - app.install_file_index(index); - - app.set_input("@input_handling".into()); - app.editor.cursor_pos = app.input().len(); - app.update_suggestions(); - - assert_eq!( - app.suggestion_kind(), - crate::editor::SuggestionKind::Path, - "expected Path suggestion kind, got {:?}", - app.suggestion_kind() - ); - let suggestions: Vec<&str> = app.suggestions().iter().map(|s| s.name.as_str()).collect(); - assert!( - suggestions.contains(&"crates/lash-cli/src/interactive/input_handling.rs"), - "expected fuzzy match to surface input_handling.rs; got {suggestions:?}" - ); -} - -#[test] -fn at_completion_with_no_index_disables_popup() { - // Wholehog: without an installed FileIndex, `@`-completion is silent. - // No fallback to the old prefix-only directory listing. - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.set_input("@anything".into()); - app.editor.cursor_pos = app.input().len(); - app.update_suggestions(); - - assert!( - app.suggestions().is_empty(), - "expected no suggestions without installed index, got {:?}", - app.suggestions() - ); - assert_eq!(app.suggestion_kind(), crate::editor::SuggestionKind::None); -} - -#[test] -fn at_completion_threads_match_indices_for_highlighting() { - // Match indices from nucleo are plumbed through editor::Suggestion so the - // popup can bold matched characters. Without them the highlight-on-match - // feature is dead code. - use std::fs; - use tempfile::TempDir; - - let dir = TempDir::new().unwrap(); - fs::create_dir_all(dir.path().join("src")).unwrap(); - fs::write(dir.path().join("src/handler.rs"), "").unwrap(); - - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let index = lash_file_index::FileIndex::for_root_blocking(dir.path().to_path_buf()); - app.install_file_index(index); - - app.set_input("@handler".into()); - app.editor.cursor_pos = app.input().len(); - app.update_suggestions(); - - let suggestion = app - .suggestions() - .iter() - .find(|s| s.name == "src/handler.rs") - .expect("expected src/handler.rs in suggestions"); - assert!( - !suggestion.match_indices.is_empty(), - "expected non-empty match_indices for fuzzy match, got {:?}", - suggestion.match_indices - ); - // Indices reference char offsets within `name`, so all must be in range. - let name_len = suggestion.name.chars().count() as u32; - assert!( - suggestion.match_indices.iter().all(|&i| i < name_len), - "match_indices out of bounds for {:?}: {:?}", - suggestion.name, - suggestion.match_indices - ); -} diff --git a/crates/lash-cli/src/app/view.rs b/crates/lash-cli/src/app/view.rs index 48bbe12..833eb4d 100644 --- a/crates/lash-cli/src/app/view.rs +++ b/crates/lash-cli/src/app/view.rs @@ -38,15 +38,6 @@ impl App { self.editor.next_image_marker_id() } - #[cfg(test)] - pub fn add_pending_image(&mut self, png_bytes: Vec) -> usize { - let id = self.next_image_marker_id(); - self.editor - .pending_images - .push(PendingImage { id, png_bytes }); - id - } - pub fn begin_pending_image(&mut self, id: usize) { self.editor.begin_pending_image(id); } diff --git a/crates/lash-cli/src/autonomous.rs b/crates/lash-cli/src/autonomous.rs index fc0bf97..a01caa0 100644 --- a/crates/lash-cli/src/autonomous.rs +++ b/crates/lash-cli/src/autonomous.rs @@ -1,12 +1,12 @@ use std::io::{self, BufRead, Write}; use futures_util::StreamExt as _; +use lash::TurnOutcome; use lash::{ LashSession, TurnActivity, TurnEvent, TurnInput, observe::{SessionObservationEventPayload, SessionObservationStreamItem}, usage::{SessionUsageReport, TokenLedgerEntry, diff_usage_reports}, }; -use lash_core::TurnOutcome; use crate::SkillCatalog; use crate::app::PreparedTurn; @@ -20,22 +20,25 @@ use crate::util; pub const AUTONOMOUS_JSON_VERSION: u32 = 1; /// Human-facing status token for a completed tool call in Print mode. -fn tool_status_label(status: lash_core::ToolCallStatus) -> &'static str { +#[derive(Clone, Copy)] +enum ToolStatus { + Success, + Failure, + Cancelled, +} + +fn tool_status_label(status: ToolStatus) -> &'static str { match status { - lash_core::ToolCallStatus::Success => "ok", - lash_core::ToolCallStatus::Failure => "error", - lash_core::ToolCallStatus::Cancelled => "cancelled", + ToolStatus::Success => "ok", + ToolStatus::Failure => "error", + ToolStatus::Cancelled => "cancelled", } } /// Render the Print-mode `[tool]` summary line for a completed tool call. /// Sub-second durations are elided to keep the stream quiet (see /// [`util::format_duration_ms_if_visible`]). -pub(crate) fn format_tool_line( - name: &str, - status: lash_core::ToolCallStatus, - duration_ms: u64, -) -> String { +fn format_tool_line(name: &str, status: ToolStatus, duration_ms: u64) -> String { let status = tool_status_label(status); match util::format_duration_ms_if_visible(duration_ms) { Some(duration_text) => format!("[tool] {name} · {status} · {duration_text}"), @@ -87,7 +90,12 @@ impl AutonomousRenderer { duration_ms, .. } => { - eprintln!("{}", format_tool_line(&name, output.status(), duration_ms)); + let status = match output.outcome { + lash_core::ToolCallOutcome::Success(_) => ToolStatus::Success, + lash_core::ToolCallOutcome::Failure(_) => ToolStatus::Failure, + lash_core::ToolCallOutcome::Cancelled(_) => ToolStatus::Cancelled, + }; + eprintln!("{}", format_tool_line(&name, status, duration_ms)); } TurnEvent::ModelRequestStarted { protocol_iteration } => { eprintln!("[thinking] step {}", protocol_iteration + 1); @@ -121,7 +129,9 @@ impl AutonomousRenderer { | TurnEvent::CodeBlockStarted { .. } | TurnEvent::CodeBlockCompleted { .. } | TurnEvent::FinalValue { .. } - | TurnEvent::ToolValue { .. } => {} + | TurnEvent::ToolValue { .. } + | TurnEvent::ModelCallRecorded { .. } + | TurnEvent::ToolIntentOutcome { .. } => {} } Ok(()) } @@ -194,7 +204,7 @@ fn json_event_record( fn json_finish_record( stream_id: u64, request_id: Option<&serde_json::Value>, - turn: &lash::TurnResult, + turn: &lash::TurnReport, cancelled: bool, ) -> serde_json::Value { let ok = matches!( @@ -257,7 +267,7 @@ impl JsonRenderer { self.write_record(record) } - fn finish(&mut self, turn: &lash::TurnResult, cancelled: bool) -> anyhow::Result<()> { + fn finish(&mut self, turn: &lash::TurnReport, cancelled: bool) -> anyhow::Result<()> { let record = json_finish_record(self.stream_id, self.request_id.as_ref(), turn, cancelled); self.write_record(record) } @@ -286,7 +296,7 @@ impl AutonomousOutput { } } - fn finish_success(&mut self, turn: &lash::TurnResult, cancelled: bool) -> anyhow::Result<()> { + fn finish_success(&mut self, turn: &lash::TurnReport, cancelled: bool) -> anyhow::Result<()> { match self { AutonomousOutput::Print(renderer) => { if !turn.assistant_output.safe_text.is_empty() { @@ -304,7 +314,7 @@ impl AutonomousOutput { } eprintln!("error: model returned malformed assistant output: {preview}"); } - std::process::exit(2); + return Err(anyhow::anyhow!("model returned no usable assistant output")); } Ok(()) } @@ -312,7 +322,7 @@ impl AutonomousOutput { } } - fn finish_failure(&mut self, turn: &lash::TurnResult, cancelled: bool) -> anyhow::Result<()> { + fn finish_failure(&mut self, turn: &lash::TurnReport, cancelled: bool) -> anyhow::Result<()> { match self { AutonomousOutput::Print(_) => { for issue in &turn.errors { @@ -338,11 +348,12 @@ async fn run_autonomous_turn( turn_input: TurnInput, output: &mut AutonomousOutput, stream_id: u64, + effect_host: std::sync::Arc, ) -> anyhow::Result { let observable = session.observe(); let cursor = observable.current_observation().cursor; let mut observation = Some(observable.subscribe_and_recover(cursor)); - let (cancel, return_rx) = spawn_session_turn(session, turn_input, stream_id); + let (cancel, return_rx) = spawn_session_turn(session, turn_input, stream_id, effect_host); #[cfg(unix)] { let cancel = cancel.clone(); @@ -367,14 +378,11 @@ async fn run_autonomous_turn( observation.as_mut()?.next().await }, if !observation_finished => { match next { - Some(Ok(SessionObservationStreamItem::Event(event))) => match event.payload { + Some(Ok(SessionObservationStreamItem::Event(event))) => match event.payload.clone() { SessionObservationEventPayload::TurnActivity(activity) => { match output.handle(activity) { Ok(()) => {} - Err(err) => { - eprintln!("error: {err}"); - std::process::exit(2); - } + Err(err) => return Err(err), } } SessionObservationEventPayload::Committed { .. } => { @@ -382,7 +390,8 @@ async fn run_autonomous_turn( } SessionObservationEventPayload::QueueChanged { .. } | SessionObservationEventPayload::ProcessChanged { .. } - | SessionObservationEventPayload::AgentFrameSwitched { .. } => {} + | SessionObservationEventPayload::AgentFrameSwitched { .. } + | SessionObservationEventPayload::ResidentChanged { .. } => {} }, Some(Ok(SessionObservationStreamItem::Gap { gap, .. })) => { eprintln!( @@ -431,13 +440,14 @@ async fn run_prepared_autonomous_turn( rlm_projected_bindings: Option, stream_id: u64, output: &mut AutonomousOutput, + effect_host: std::sync::Arc, ) -> anyhow::Result { let prepared = PreparedTurn::prepare(prompt, Vec::new(), skills); let mut turn_input = make_turn_input(&prepared); if let Some(bindings) = rlm_projected_bindings { turn_input = lash_protocol_rlm::RlmTurnInputExt::rlm_project(turn_input, bindings)?; } - run_autonomous_turn(session, turn_input, output, stream_id).await + run_autonomous_turn(session, turn_input, output, stream_id, effect_host).await } async fn finish_autonomous_outcome( @@ -456,13 +466,13 @@ async fn finish_autonomous_outcome( TurnOutcome::Finished(_) | TurnOutcome::AgentFrameSwitch { .. } => { output.finish_success(&done.result, cancel.is_cancelled())?; if cancel.is_cancelled() && matches!(output, AutonomousOutput::Print(_)) { - std::process::exit(1); + return Err(anyhow::anyhow!("autonomous turn was cancelled")); } } TurnOutcome::Stopped(_) => { output.finish_failure(&done.result, cancel.is_cancelled())?; if matches!(output, AutonomousOutput::Print(_)) { - std::process::exit(1); + return Err(anyhow::anyhow!("autonomous turn stopped")); } } } @@ -478,9 +488,10 @@ pub(crate) async fn run_autonomous( persistence: AutonomousPersistenceContext, rlm_projected_bindings: Option, mode: AutonomousMode, + effect_host: std::sync::Arc, ) -> anyhow::Result<()> { if mode == AutonomousMode::Rpc { - return run_rpc(session, skills, persistence).await; + return run_rpc(session, skills, persistence, effect_host).await; } let before_usage = session.usage_report(); let mut output = match mode { @@ -495,6 +506,7 @@ pub(crate) async fn run_autonomous( rlm_projected_bindings, 1, &mut output, + effect_host, ) .await?; let (done, cancelled) = @@ -576,6 +588,7 @@ async fn run_rpc( session: LashSession, skills: SkillCatalog, persistence: AutonomousPersistenceContext, + effect_host: std::sync::Arc, ) -> anyhow::Result<()> { rpc_write(serde_json::json!({ "type": "ready", @@ -647,6 +660,7 @@ async fn run_rpc( None, stream_id, &mut output, + std::sync::Arc::clone(&effect_host), ) .await?; let (done, cancelled) = @@ -677,11 +691,10 @@ async fn run_rpc( #[cfg(test)] mod tests { use super::*; - use lash::TurnResult; + use lash::turn::AssistantOutput; + use lash::{TurnExecutionMetrics, TurnFinish, TurnOutcome, TurnReport, TurnStop}; use lash_core::{ - AssistantOutput, ExecutionSummary, OutputState, SessionSnapshot, TokenUsage, - ToolCallOutput, ToolCallRecord, ToolCallStatus, TurnActivityId, TurnFinish, TurnOutcome, - TurnStop, + SessionPolicy, SessionSnapshot, TokenUsage, ToolCallOutput, ToolCallRecord, TurnActivityId, }; use serde_json::json; @@ -701,18 +714,19 @@ mod tests { } } - fn sample_turn_result() -> TurnResult { - TurnResult { - state: SessionSnapshot::default(), + fn sample_turn_result() -> TurnReport { + let assistant_output: AssistantOutput = serde_json::from_value(json!({ + "safe_text": "hello", + "raw_text": "hello", + "state": "usable" + })) + .expect("assistant output"); + TurnReport { + state: SessionSnapshot::new(SessionPolicy::new(crate::host_policy::turn_budget())), outcome: TurnOutcome::Finished(TurnFinish::AssistantMessage { text: "hello".to_string(), }), - cancellation: None, - assistant_output: AssistantOutput { - safe_text: "hello".to_string(), - raw_text: "hello".to_string(), - state: OutputState::Usable, - }, + assistant_output, usage: TokenUsage { input_tokens: 3, output_tokens: 4, @@ -727,13 +741,14 @@ mod tests { output: ToolCallOutput::success("ok"), duration_ms: 5, }], - execution: ExecutionSummary { + execution: TurnExecutionMetrics { had_tool_calls: true, had_code_execution: false, started_at_ms: 1_000, duration_ms: 42, }, errors: Vec::new(), + acceptance: None, } } @@ -848,7 +863,7 @@ mod tests { #[test] fn tool_line_renders_status_and_visible_duration() { assert_eq!( - format_tool_line("read_file", ToolCallStatus::Success, 1_500), + format_tool_line("read_file", ToolStatus::Success, 1_500), "[tool] read_file · ok · 1.5s" ); } @@ -856,7 +871,7 @@ mod tests { #[test] fn tool_line_elides_subsecond_duration() { assert_eq!( - format_tool_line("read_file", ToolCallStatus::Success, 500), + format_tool_line("read_file", ToolStatus::Success, 500), "[tool] read_file · ok" ); } @@ -864,7 +879,7 @@ mod tests { #[test] fn tool_line_renders_failure_status() { assert_eq!( - format_tool_line("write_file", ToolCallStatus::Failure, 2_000), + format_tool_line("write_file", ToolStatus::Failure, 2_000), "[tool] write_file · error · 2.0s" ); } @@ -872,7 +887,7 @@ mod tests { #[test] fn tool_line_renders_cancelled_status() { assert_eq!( - format_tool_line("shell", ToolCallStatus::Cancelled, 0), + format_tool_line("shell", ToolStatus::Cancelled, 0), "[tool] shell · cancelled" ); } diff --git a/crates/lash-cli/src/capability_catalog.rs b/crates/lash-cli/src/capability_catalog.rs index 76820a9..c14c79a 100644 --- a/crates/lash-cli/src/capability_catalog.rs +++ b/crates/lash-cli/src/capability_catalog.rs @@ -115,6 +115,7 @@ impl Row { }), cache_control: self.cache_control, stream_termination: None, + sampling: Default::default(), } } } diff --git a/crates/lash-cli/src/config.rs b/crates/lash-cli/src/config.rs index 5ed00ab..81a8e74 100644 --- a/crates/lash-cli/src/config.rs +++ b/crates/lash-cli/src/config.rs @@ -6,13 +6,11 @@ use std::collections::BTreeMap; -use lash_core::{ProviderFactory, ProviderHandle, ProviderSpec}; +use lash::provider::{Provider, ProviderHandle, ProviderOptions, StreamTermination}; use lash_plugin_mcp::McpServerConfig; -use lash_provider_anthropic::AnthropicProviderFactory; -use lash_provider_google::GoogleOAuthProviderFactory; -use lash_provider_openai::{ - CodexProviderFactory, OpenAiCompatibleProviderFactory, OpenAiProviderFactory, -}; +use lash_provider_anthropic::AnthropicProvider; +use lash_provider_google::{GoogleOAuthClient, GoogleOAuthProvider}; +use lash_provider_openai::{CodexProvider, OpenAiCompat, OpenAiCompatibleProvider, OpenAiProvider}; use serde::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -71,6 +69,69 @@ pub struct ModelDefault { pub variant: Option, } +/// Host-owned provider persistence record. +/// +/// This deliberately preserves the former flat `{ "type", ... }` JSON shape +/// so existing `~/.lash/config.json` credentials remain readable while +/// provider construction stays outside Lash. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProviderConfig { + pub kind: String, + pub config: serde_json::Value, +} + +impl Serialize for ProviderConfig { + fn serialize(&self, serializer: S) -> Result { + let mut value = match &self.config { + serde_json::Value::Object(map) => serde_json::Value::Object(map.clone()), + serde_json::Value::Null => serde_json::Value::Object(serde_json::Map::new()), + other => { + return Err(serde::ser::Error::custom(format!( + "ProviderConfig.config must be a JSON object, got {other}" + ))); + } + }; + if let serde_json::Value::Object(map) = &mut value { + map.insert( + "type".to_string(), + serde_json::Value::String(self.kind.clone()), + ); + } + value.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ProviderConfig { + fn deserialize>(deserializer: D) -> Result { + let mut value = serde_json::Value::deserialize(deserializer)?; + let kind = if let serde_json::Value::Object(map) = &mut value { + let raw = map + .remove("type") + .ok_or_else(|| serde::de::Error::missing_field("type"))?; + raw.as_str() + .ok_or_else(|| serde::de::Error::custom("provider `type` must be a string"))? + .to_string() + } else { + return Err(serde::de::Error::custom( + "provider config must be a JSON object", + )); + }; + Ok(Self { + kind, + config: value, + }) + } +} + +impl ProviderConfig { + pub fn from_provider(provider: &impl Provider) -> Self { + Self { + kind: provider.kind().to_string(), + config: provider.serialize_config(), + } + } +} + /// Result of attempting to read `config.json`. #[derive(Clone, Debug)] pub enum ConfigLoadOutcome { @@ -111,7 +172,13 @@ pub struct LashConfig { pub active_provider: String, #[serde(default)] pub theme: ThemeName, - pub providers: BTreeMap, + pub providers: BTreeMap, + /// Default execution mode for newly created sessions. + #[serde(default)] + pub execution_mode: crate::execution_settings::ExecutionMode, + /// Default RLM dialect for newly created RLM sessions. + #[serde(default)] + pub rlm_dialect: crate::execution_settings::RlmDialect, #[serde(default, skip_serializing_if = "AuxiliarySecrets::is_empty")] pub auxiliary_secrets: AuxiliarySecrets, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] @@ -128,17 +195,17 @@ pub struct LashConfig { } impl LashConfig { - /// Construct a config from an already-built provider handle. The - /// provider's current spec becomes the active entry. - pub fn new(provider: &ProviderHandle) -> Self { - let spec = provider.to_spec(); - let kind = spec.kind.clone(); + /// Construct a config from an already-serialized provider record. + pub fn new(provider: ProviderConfig) -> Self { + let kind = provider.kind.clone(); let mut providers = BTreeMap::new(); - providers.insert(kind.clone(), spec); + providers.insert(kind.clone(), provider); Self { active_provider: kind, theme: ThemeName::default(), providers, + execution_mode: crate::execution_settings::ExecutionMode::default(), + rlm_dialect: crate::execution_settings::RlmDialect::default(), auxiliary_secrets: AuxiliarySecrets::default(), mcp_servers: BTreeMap::new(), agent_models: BTreeMap::new(), @@ -146,7 +213,7 @@ impl LashConfig { } } - pub fn active_provider_spec(&self) -> &ProviderSpec { + pub fn active_provider_config(&self) -> &ProviderConfig { self.providers .get(&self.active_provider) .expect("active provider missing from config") @@ -164,7 +231,7 @@ impl LashConfig { Ok(()) } - pub fn provider_spec(&self, kind: &str) -> Option<&ProviderSpec> { + pub fn provider_config(&self, kind: &str) -> Option<&ProviderConfig> { self.providers.get(kind) } @@ -176,15 +243,11 @@ impl LashConfig { self.providers.contains_key(kind) } - pub fn upsert_provider_spec(&mut self, spec: ProviderSpec) { - self.providers.insert(spec.kind.clone(), spec); - } - - pub fn upsert_provider(&mut self, provider: &ProviderHandle) { - self.upsert_provider_spec(provider.to_spec()); + pub fn upsert_provider(&mut self, provider: ProviderConfig) { + self.providers.insert(provider.kind.clone(), provider); } - pub fn remove_provider(&mut self, kind: &str) -> Option { + pub fn remove_provider(&mut self, kind: &str) -> Option { let removed = self.providers.remove(kind)?; if self.providers.is_empty() { return Some(removed); @@ -225,7 +288,7 @@ impl LashConfig { /// Materialize the active provider with the providers compiled into the CLI. pub fn build_active_provider(&self) -> Result { - materialize_provider_spec(self.active_provider_spec()) + materialize_provider(self.active_provider_config()) } /// Load from the given config path. Returns `None` if missing or @@ -304,26 +367,180 @@ impl LashConfig { } } -pub(crate) fn materialize_provider_spec(spec: &ProviderSpec) -> Result { - let components = match spec.kind.as_str() { - "anthropic" => AnthropicProviderFactory.deserialize(spec.config.clone()), - "openai" => OpenAiProviderFactory.deserialize(spec.config.clone()), - "openai-compatible" => OpenAiCompatibleProviderFactory.deserialize(spec.config.clone()), - "codex" => CodexProviderFactory.deserialize(spec.config.clone()), - "google_oauth" => GoogleOAuthProviderFactory.deserialize(spec.config.clone()), +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AnthropicConfig { + api_key: String, + #[serde(default)] + base_url: Option, + #[serde(default)] + options: ProviderOptions, + #[serde(default = "require_terminal_evidence")] + stream_termination: StreamTermination, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct OpenAiConfig { + api_key: String, + #[serde(default)] + options: ProviderOptions, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct OpenAiCompatibleConfig { + api_key: String, + base_url: String, + #[serde(default)] + options: ProviderOptions, + #[serde(default)] + compat: OpenAiCompat, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CodexConfig { + access_token: String, + refresh_token: String, + expires_at: u64, + #[serde(default)] + account_id: Option, + #[serde(default)] + options: ProviderOptions, + #[serde(default)] + transport: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct GoogleConfig { + access_token: String, + refresh_token: String, + expires_at: u64, + #[serde(default)] + oauth_client_id: Option, + #[serde(default)] + oauth_client_secret: Option, + #[serde(default)] + endpoint: Option, + #[serde(default)] + api_version: Option, + #[serde(default)] + project_id: Option, + #[serde(default)] + options: ProviderOptions, + #[serde(default = "eof_tolerated")] + stream_termination: StreamTermination, +} + +fn require_terminal_evidence() -> StreamTermination { + StreamTermination::RequireTerminalEvidence +} + +fn eof_tolerated() -> StreamTermination { + StreamTermination::EofTolerated +} + +fn decode Deserialize<'de>>(config: &ProviderConfig) -> Result { + serde_json::from_value(config.config.clone()) + .map_err(|error| format!("invalid {} provider config: {error}", config.kind)) +} + +fn google_oauth_client(config: &GoogleConfig) -> Result { + let id = config + .oauth_client_id + .clone() + .or_else(|| std::env::var("LASH_GOOGLE_CLIENT_ID").ok()); + let secret = config + .oauth_client_secret + .clone() + .or_else(|| std::env::var("LASH_GOOGLE_CLIENT_SECRET").ok()); + match (id, secret) { + (Some(id), Some(secret)) if !id.trim().is_empty() && !secret.trim().is_empty() => { + Ok(GoogleOAuthClient { id, secret }) + } + _ => Err( + "google_oauth provider config requires oauth_client_id/oauth_client_secret or both LASH_GOOGLE_CLIENT_ID and LASH_GOOGLE_CLIENT_SECRET" + .to_string(), + ), + } +} + +pub fn materialize_provider(config: &ProviderConfig) -> Result { + let components = match config.kind.as_str() { + "anthropic" => { + let cfg: AnthropicConfig = decode(config)?; + AnthropicProvider::new(cfg.api_key) + .with_base_url(cfg.base_url) + .with_options(cfg.options) + .with_stream_termination(cfg.stream_termination) + .into_components() + } + "openai" => { + let cfg: OpenAiConfig = decode(config)?; + OpenAiProvider::new(cfg.api_key) + .with_options(cfg.options) + .into_components() + } + "openai-compatible" => { + let cfg: OpenAiCompatibleConfig = decode(config)?; + OpenAiCompatibleProvider::new(cfg.api_key, cfg.base_url) + .with_options(cfg.options) + .with_compat(cfg.compat) + .into_components() + } + "codex" => { + let cfg: CodexConfig = decode(config)?; + let mut provider = + CodexProvider::new(cfg.access_token, cfg.refresh_token, cfg.expires_at) + .with_account_id(cfg.account_id) + .with_options(cfg.options); + match cfg.transport.as_deref() { + None | Some("auto") => {} + Some("sse") => provider = provider.force_sse_transport(), + Some(other) => { + return Err(format!( + "codex transport `{other}` cannot be restored by this provider API" + )); + } + } + provider.into_components() + } + "google_oauth" => { + let cfg: GoogleConfig = decode(config)?; + let oauth_client = google_oauth_client(&cfg)?; + let mut provider = GoogleOAuthProvider::new( + cfg.access_token, + cfg.refresh_token, + cfg.expires_at, + oauth_client, + ) + .with_project_id(cfg.project_id) + .with_options(cfg.options) + .with_stream_termination(cfg.stream_termination); + if let Some(endpoint) = cfg.endpoint { + provider = provider.with_endpoint(endpoint); + } + if let Some(api_version) = cfg.api_version { + provider = provider.with_api_version(api_version); + } + provider.into_components() + } #[cfg(feature = "test-provider")] - "test" => return materialize_test_provider_spec(spec), - other => Err(format!( - "provider `{}` is not supported by this CLI build", - other - )), - }?; + "test" => return materialize_test_provider(config), + other => { + return Err(format!( + "provider `{other}` is not supported by this CLI build" + )); + } + }; Ok(ProviderHandle::new(components)) } #[cfg(feature = "test-provider")] -fn materialize_test_provider_spec(spec: &ProviderSpec) -> Result { - let scenario = spec +fn materialize_test_provider(config: &ProviderConfig) -> Result { + let scenario = config .config .get("scenario") .and_then(serde_json::Value::as_str) @@ -333,12 +550,38 @@ fn materialize_test_provider_spec(spec: &ProviderSpec) -> Result Ok(standard_slow_echo_provider().into_handle()), "standard-gated-escape" => Ok(standard_gated_escape_provider().into_handle()), "rlm-subagent-smoke" => Ok(rlm_subagent_smoke_provider().into_handle()), + "rlm-typescript-smoke" => Ok(rlm_typescript_smoke_provider().into_handle()), "rlm-workspace-smoke" => Ok(rlm_workspace_smoke_provider().into_handle()), "rlm-nonzero-exit-smoke" => Ok(rlm_nonzero_exit_smoke_provider().into_handle()), other => Err(format!("unknown CLI test provider scenario `{other}`")), } } +#[cfg(feature = "test-provider")] +fn rlm_typescript_smoke_provider() -> lash_core::testing::TestProvider { + lash_core::testing::TestProvider::builder() + .kind("test") + .serialize_config(|| { + serde_json::json!({ + "scenario": "rlm-typescript-smoke", + }) + }) + .complete(|_request| async move { + let response = r#" +finish("typescript-ok"); +"#; + Ok(lash_core::LlmResponse { + full_text: response.to_string(), + parts: vec![lash_core::llm::types::LlmOutputPart::Text { + text: response.to_string(), + response_meta: None, + }], + ..Default::default() + }) + }) + .build() +} + #[cfg(feature = "test-provider")] fn standard_echo_provider() -> lash_core::testing::TestProvider { lash_core::testing::TestProvider::builder() @@ -702,14 +945,23 @@ mod tests { } } }); - let cfg: LashConfig = serde_json::from_value(raw).expect("valid config"); + let cfg: LashConfig = serde_json::from_value(raw.clone()).expect("valid config"); assert_eq!(cfg.active_provider, "openai"); assert_eq!(cfg.theme, ThemeName::Lash); - let spec = cfg.active_provider_spec(); + let spec = cfg.active_provider_config(); assert_eq!(spec.kind, "openai"); assert_eq!(spec.config["api_key"], serde_json::json!("direct-k")); - let compatible = cfg.provider_spec("openai-compatible").expect("compatible"); + let compatible = cfg + .provider_config("openai-compatible") + .expect("compatible"); assert_eq!(compatible.config["base_url"], "https://example.com/v1"); + + let rendered = serde_json::to_value(&cfg).expect("serialize config"); + assert_eq!(rendered["providers"]["openai"], raw["providers"]["openai"]); + assert_eq!( + rendered["providers"]["openai-compatible"], + raw["providers"]["openai-compatible"] + ); } #[test] diff --git a/crates/lash-cli/src/event.rs b/crates/lash-cli/src/event.rs index 64f5586..ecdd5fe 100644 --- a/crates/lash-cli/src/event.rs +++ b/crates/lash-cli/src/event.rs @@ -1,5 +1,6 @@ use crossterm::event::Event as TermEvent; -use lash_core::{ProcessHandleSummary, SessionProcessEventKind, TurnActivity}; +use lash::process::ProcessHandleView; +use lash_core::{SessionProcessEventKind, TurnActivity}; use lash_tui_extensions::TuiHostEffect; use tokio::sync::mpsc; @@ -54,7 +55,7 @@ pub enum AppEvent { pub struct UiSnapshotResult { pub effects: Vec, - pub processes: Option>, + pub processes: Option>, pub duration: std::time::Duration, pub timed_out: bool, pub diagnostics: Vec, diff --git a/crates/lash-cli/src/examples/mcp_discovery/mod.rs b/crates/lash-cli/src/examples/mcp_discovery/mod.rs deleted file mode 100644 index b67922e..0000000 --- a/crates/lash-cli/src/examples/mcp_discovery/mod.rs +++ /dev/null @@ -1,370 +0,0 @@ -//! Reference MCP tool-discovery example. -//! -//! This is host policy, not a lash primitive: lash ships no tool discovery. The -//! example shows the recommended way to make a large MCP tool set discoverable -//! under the flat Tool Catalog + RLM deferred-resolution model: -//! -//! 1. Enumerate MCP tools and build a ranking index ([`ranking`]). -//! 2. Advertise them through a catalogue-preview prompt contribution -//! ([`lash_lashlang_runtime::catalogue_preview_contribution`]). -//! 3. Expose a `search_tools` host tool over the index ([`definitions`], -//! [`service`]). -//! 4. Register a [`DeferredToolResolver`](lash_lashlang_runtime::DeferredToolResolver) -//! that resolves chosen MCP call-paths into a Tool Grant + Tool Execution -//! Binding ([`resolver`]). -//! -//! The index ranking (BM25 / optional semantic / RRF) lives here as a reference -//! example hosts can copy or adapt. The catalogue-preview formatter is a public -//! helper in `lash-lashlang-runtime`. - -mod catalog; -mod common; -mod definitions; -mod plugin; -mod ranking; -mod rerank; -mod resolver; -mod schema_index; -mod service; - -pub use plugin::ToolDiscoveryPluginFactory; -pub use resolver::{McpCatalogedTool, McpDeferredToolResolver}; - -use std::sync::Arc; - -use lash_core::ToolProvider; -use serde_json::json; - -/// Enumerate provider tools into the shared MCP discovery/deferred record. -pub fn mcp_cataloged_tools(server: &str, provider: &dyn ToolProvider) -> Vec { - provider - .tool_manifests() - .into_iter() - .filter_map(|manifest| { - let contract = provider.resolve_contract(&manifest.name)?; - Some(McpCatalogedTool { - server: server.to_string(), - definition: lash_core::ToolDefinition::from_parts( - manifest, - Arc::unwrap_or_clone(contract), - ), - }) - }) - .collect() -} - -/// Project enumerated MCP tools to the JSON catalog consumed by `search_tools` -/// and the catalogue-preview prompt contribution. -pub fn mcp_catalog_records(tools: &[McpCatalogedTool]) -> Vec { - tools - .iter() - .map(|tool| { - let manifest = tool.definition.manifest(); - json!({ - "id": manifest.id, - "name": manifest.name, - "description": manifest.description, - "bindings": manifest.bindings, - "activation": manifest.activation, - "contract": manifest.compact_contract, - }) - }) - .collect() -} - -/// End-to-end test of the reference MCP-discovery loop: discover -> preview -> -/// search_tools -> call -> resolve -> execute. The per-module unit tests cover -/// the pieces in isolation; this proves they compose through one real RLM turn -/// over the exact plugin stack, prompt layer, and deferred resolver the CLI -/// startup wires (`startup::cli_prompt_config`, `ToolDiscoveryPluginFactory`, -/// and an `McpDeferredToolResolver`). -/// -/// The model's program searches the non-resident MCP catalog, then references -/// `appworld.venmo_send`, a call-path the resident host environment does not -/// provide. Linking defers to the resolver, which folds the grant for tool id -/// `tool:mcp/venmo_send`; execution then routes by grant id to the hidden MCP -/// provider without adding the tool to the catalog. -/// -/// Hard constraint / compromise: the test builds the session with an ephemeral -/// `InlineEffectHost` rather than `CliSessionOpener`'s durable `SqliteEffectHost`. -/// A bare in-process turn cannot drive the durable effect host to completion — -/// its replay/journal lifecycle is pumped by the CLI's autonomous/interactive -/// runner, not by a unit test awaiting a single turn. Swapping only the effect -/// host (the supported in-process turn path) keeps every flat-catalog and -/// deferred-resolution code path under test. -#[cfg(all(test, feature = "test-provider"))] -mod gate4_e2e { - use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering}; - - use lash::PromptLayerSink; - use lash::durability::InlineEffectHost; - use lash::persistence::{ - InMemoryAttachmentStore, InMemoryLashlangArtifactStore, InMemoryProcessExecutionEnvStore, - }; - use lash::tools::SharedDeferredToolResolver; - use lash_core::plugin::{PluginSpec, StaticPluginFactory}; - use lash_core::{ - InMemorySessionStoreFactory, ModelSpec, ToolCall, ToolContract, ToolDefinition, - ToolManifest, ToolProvider, ToolResult, - }; - use lash_tool_support::{LashlangToolBinding, ToolDefinitionLashlangExt}; - use serde_json::json; - - use crate::examples::mcp_discovery::{ - McpCatalogedTool, McpDeferredToolResolver, ToolDiscoveryPluginFactory, - }; - use crate::execution_settings::ExecutionMode; - - /// A stand-in MCP server tool: `appworld.venmo_send`, reachable by its - /// Lashlang call-path. It counts executions so the test can prove the - /// deferred-resolved call actually ran. - struct HiddenVenmoProvider { - definition: ToolDefinition, - executions: Arc, - } - - #[async_trait::async_trait] - impl ToolProvider for HiddenVenmoProvider { - fn tool_manifests(&self) -> Vec { - Vec::new() - } - - fn resolve_manifest_by_id(&self, id: &lash_core::ToolId) -> Option { - (id == &self.definition.manifest.id).then(|| self.definition.manifest()) - } - - fn resolve_contract(&self, name: &str) -> Option> { - (name == self.definition.name()).then(|| Arc::new(self.definition.contract())) - } - - async fn execute(&self, call: ToolCall<'_>) -> ToolResult { - assert_eq!(call.name, "venmo_send"); - self.executions.fetch_add(1, Ordering::SeqCst); - let amount = call.args.get("amount").cloned().unwrap_or(json!(0)); - ToolResult::ok(json!({ "sent": true, "amount": amount })) - } - } - - /// One venmo tool definition (always `tool:mcp/venmo_send`) bound under the - /// deferred `appworld.venmo_send` call-path the model invents. - fn venmo_definition(module: &str, operation: &str) -> ToolDefinition { - ToolDefinition::raw( - "tool:mcp/venmo_send", - "venmo_send", - "Send a Venmo payment to a receiver.", - ToolContract::default_input_schema(), - json!({ "type": "object" }), - ) - .with_lashlang_binding(LashlangToolBinding::new([module], operation)) - } - - /// Scripted RLM provider: a single Lashlang block that searches the catalog - /// (`tools.search`), calls the deferred MCP call-path - /// (`appworld.venmo_send`, resolved on demand), and finishs the routed - /// result. Every model request is captured so the test can assert the - /// catalogue-preview prompt and the `search_tools` host tool reached the - /// model. - fn scripted_provider( - prompts: Arc>>, - ) -> lash_core::provider::ProviderHandle { - lash_core::testing::TestProvider::builder() - .kind("test") - .complete(move |request| { - let prompts = Arc::clone(&prompts); - async move { - let joined = request - .messages - .iter() - .flat_map(|message| message.blocks.iter()) - .filter_map(|block| match block { - lash_core::llm::types::LlmContentBlock::Text { text, .. } => { - Some(text.to_string()) - } - _ => None, - }) - .collect::>() - .join("\n"); - prompts.lock().expect("prompts lock").push(joined.clone()); - // `search_tools` reranks its candidates through a direct LLM - // completion; that request flows through this same provider. - // Answer it with the JSON tool-name schema the reranker - // expects so search succeeds and the turn proceeds. - let response = if joined.contains("select API tools") - || joined.contains("Select tools from candidates") - { - "{\"tool_names\":[\"venmo_send\"]}".to_string() - } else { - // search_tools (discover) -> deferred resolve + execute of - // `appworld.venmo_send` -> finish, all in one turn so the - // loop is deterministic and terminates immediately. - r#" -hits = await tools.search({ query: "venmo send money", module: "appworld" })? -if hits[0].call != "appworld.venmo_send" { - finish { value: "search-miss", discovered: hits } -} -result = await appworld.venmo_send({ amount: 25 })? -finish { value: "venmo-routed-ok", discovered: hits[0].call, sent: result.sent } -"# - .to_string() - }; - Ok(lash_core::LlmResponse { - full_text: response.clone(), - parts: vec![lash_core::llm::types::LlmOutputPart::Text { - text: response, - response_meta: None, - }], - ..Default::default() - }) - } - }) - .build() - .into_handle() - } - - #[tokio::test] - async fn cli_mcp_discovery_loop_discovers_previews_searches_resolves_and_executes() { - let executions = Arc::new(AtomicUsize::new(0)); - let venmo_definition = venmo_definition("appworld", "venmo_send"); - // Non-resident MCP provider for `tool:mcp/venmo_send`: it advertises no - // catalog members but can resolve and execute the deferred grant's id. - let venmo_provider = Arc::new(HiddenVenmoProvider { - definition: venmo_definition.clone(), - executions: Arc::clone(&executions), - }) as Arc; - - // discover: build the deferred resolver the same shape `startup::run` - // does. The path the model invents is absent from the resident host - // environment, so linking must defer to the resolver. - let cataloged_tools = vec![McpCatalogedTool { - server: "appworld".to_string(), - definition: venmo_definition, - }]; - let discovery_catalog = - crate::examples::mcp_discovery::mcp_catalog_records(&cataloged_tools); - let resolver: SharedDeferredToolResolver = - Arc::new(McpDeferredToolResolver::new(cataloged_tools)); - - // The live CLI plugin surface: the reference `search_tools` discovery - // plugin (preview prompt + host tool) plus a non-resident MCP provider - // that can execute only through the deferred grant route. - let mut plugin_stack = lash::PluginStack::new(); - plugin_stack.push(Arc::new(ToolDiscoveryPluginFactory::with_catalog( - discovery_catalog, - ))); - plugin_stack.push(Arc::new(StaticPluginFactory::new( - "appworld_mcp", - PluginSpec::new().with_tool_provider(Arc::clone(&venmo_provider)), - ))); - - let prompts = Arc::new(std::sync::Mutex::new(Vec::::new())); - let factory = lash_protocol_rlm::RlmProtocolPluginFactory::new( - lash_protocol_rlm::RlmProtocolPluginConfig::default(), - Arc::new(InMemoryLashlangArtifactStore::new()), - ) - .with_deferred_tool_resolver(resolver); - let core = lash::LashCore::rlm_builder(factory) - .provider(scripted_provider(Arc::clone(&prompts))) - .model( - ModelSpec::from_token_limits( - "test/cli-e2e-model", - Default::default(), - 200_000, - None, - ) - .expect("model spec"), - ) - // Safety bound: the scripted program finishs on its first turn, so a - // healthy run terminates well within this; it stops a regression from - // re-prompting forever. - .max_turns(2) - .plugins(plugin_stack) - // The CLI's own RLM prompt layer; the catalogue-preview prompt slots - // into its Execution section. - .prompt_layer(crate::startup::cli_prompt_config(true, &ExecutionMode::Rlm)) - .store_factory(Arc::new(InMemorySessionStoreFactory::new())) - .effect_host(Arc::new(InlineEffectHost::default())) - .attachment_store(Arc::new(InMemoryAttachmentStore::new())) - .process_env_store(Arc::new(InMemoryProcessExecutionEnvStore::new())) - .build() - .expect("build ephemeral RLM core"); - let session = core - .session("gate4-mcp-discovery") - .open() - .await - .expect("open RLM session"); - - // Refresh the catalog so the `search_tools` provider and catalogue - // preview see the members, as the startup pipeline does pre-first-turn. - session - .admin() - .commands() - .refresh_tool_catalog("gate4", "gate4-refresh") - .await - .expect("refresh tool catalog"); - assert!( - !session - .tools() - .active_manifests() - .await - .expect("active manifests before turn") - .iter() - .any(|manifest| manifest.id == lash_core::ToolId::from("tool:mcp/venmo_send")), - "deferred MCP tool must not be a catalog member before execution" - ); - - let output = session - .turn(lash::TurnInput::text("Send 25 on venmo")) - .run() - .await - .expect("run RLM turn"); - - // resolve + execute: linking deferred `appworld.venmo_send` to the - // resolver's grant, which dispatched by id to the non-resident venmo tool. - assert_eq!( - executions.load(Ordering::SeqCst), - 1, - "deferred-resolved venmo tool should execute exactly once; outcome: {:?}", - output.result.outcome - ); - // call -> resolve -> execute -> finish closed the loop, and the routed - // tool's output (`sent`) made it back into the final value. - assert_eq!( - output.final_value(), - Some(&json!({ - "value": "venmo-routed-ok", - "discovered": "appworld.venmo_send", - "sent": true - })), - "turn should finish the routed value; outcome: {:?}", - output.result.outcome - ); - assert!( - !session - .tools() - .active_manifests() - .await - .expect("active manifests after turn") - .iter() - .any(|manifest| manifest.id == lash_core::ToolId::from("tool:mcp/venmo_send")), - "deferred MCP execution must not mutate catalog membership" - ); - - let prompts = prompts.lock().expect("prompts lock"); - let first_prompt = prompts.first().expect("at least one model request"); - // preview: the catalogue-preview prompt advertised the indexed tail. - assert!( - first_prompt.contains("Catalogued Capabilities"), - "catalogue-preview prompt contribution did not reach the model" - ); - assert!( - first_prompt.contains("appworld.venmo_send"), - "catalogue-preview prompt did not advertise the deferred MCP call" - ); - // discover/search_tools: the host `search_tools` tool was presented. - assert!( - first_prompt.contains("search_tools") || first_prompt.contains("tools.search"), - "search_tools host tool was not advertised to the model" - ); - } -} diff --git a/crates/lash-cli/src/examples/mcp_discovery/plugin.rs b/crates/lash-cli/src/examples/mcp_discovery/plugin.rs deleted file mode 100644 index a28f1bf..0000000 --- a/crates/lash-cli/src/examples/mcp_discovery/plugin.rs +++ /dev/null @@ -1,172 +0,0 @@ -use std::sync::Arc; - -use lash_core::ToolProvider; -use lash_core::plugin::{ - PluginError, PluginFactory, PluginSessionContext, PluginSpec, SessionPlugin, - StaticPluginFactory, -}; -use lash_lashlang_runtime::catalogue_preview_contribution; - -use super::service::tool_discovery_provider_with_catalog; - -/// Plugin factory for the `search_tools` deferred-discovery catalog. -/// -/// Declares its provider through a [`PluginSpec`] driven by -/// [`StaticPluginFactory`], so it does not hand-roll the `SessionPlugin` + -/// `register` ceremony. Alongside the `search_tools` tool, it advertises the -/// deferred (non-resident) MCP tools to the model through a catalogue-preview -/// prompt contribution. `with_catalog(...)` is the CLI/RLM path for a large MCP -/// tail: `search_tools` indexes resident tools plus that explicit tail, while -/// the prompt preview lists only the deferred catalog so resident members keep -/// their full RLM docs. -pub struct ToolDiscoveryPluginFactory { - inner: StaticPluginFactory, -} - -impl ToolDiscoveryPluginFactory { - pub fn new() -> Self { - Self::with_catalog(Vec::new()) - } - - pub fn with_catalog(extra_catalog: Vec) -> Self { - if extra_catalog.is_empty() { - return Self { - inner: StaticPluginFactory::new("tool_discovery", PluginSpec::new()), - }; - } - let extra_catalog = Arc::new(extra_catalog); - let discovery_catalog = Arc::clone(&extra_catalog); - let spec = PluginSpec::new() - .with_tool_provider(Arc::new(tool_discovery_provider_with_catalog( - extra_catalog.as_ref().clone(), - )) as Arc) - .with_prompt_contributor(Arc::new( - move |_ctx: lash_core::plugin::PromptHookContext| { - let discovery_catalog = Arc::clone(&discovery_catalog); - Box::pin(async move { - Ok(catalogue_preview_contribution(discovery_catalog.as_ref()) - .into_iter() - .collect()) - }) - }, - )); - Self { - inner: StaticPluginFactory::new("tool_discovery", spec), - } - } -} - -impl Default for ToolDiscoveryPluginFactory { - fn default() -> Self { - Self::new() - } -} - -impl PluginFactory for ToolDiscoveryPluginFactory { - fn id(&self) -> &'static str { - self.inner.id() - } - - fn build(&self, ctx: &PluginSessionContext) -> Result, PluginError> { - self.inner.build(ctx) - } -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use lash_core::testing::MockSessionManager; - use lash_core::{ - PluginHost, PromptHookContext, ProtocolTurnOptions, SessionReadView, SessionSnapshot, - ToolDefinition, TurnContext, - }; - use lash_tool_support::{LashlangToolBinding, ToolDefinitionLashlangExt}; - use serde_json::json; - - use super::*; - - fn prompt_context() -> PromptHookContext { - let snapshot = SessionSnapshot::default(); - PromptHookContext { - session_id: "root".to_string(), - sessions: Arc::new(MockSessionManager::default()), - state: SessionReadView::from_snapshot(&snapshot), - protocol_turn_options: ProtocolTurnOptions::default(), - turn_context: TurnContext::default(), - } - } - - fn catalog_record() -> serde_json::Value { - let definition = ToolDefinition::raw( - "tool:mcp/venmo_send", - "venmo_send", - "Send a Venmo payment.", - json!({ "type": "object" }), - json!({ "type": "object" }), - ) - .with_lashlang_binding(LashlangToolBinding::new(["appworld"], "venmo_send")); - let manifest = definition.manifest(); - json!({ - "id": manifest.id, - "name": manifest.name, - "description": manifest.description, - "bindings": manifest.bindings, - "activation": manifest.activation, - "contract": manifest.compact_contract, - }) - } - - fn plugin_host(factory: ToolDiscoveryPluginFactory) -> PluginHost { - let mut factories = lash_core::testing::test_standard_protocol_factories(); - factories.push(Arc::new(factory)); - PluginHost::new(factories) - } - - #[tokio::test] - async fn empty_deferred_catalog_does_not_install_search_overlay() { - let host = plugin_host(ToolDiscoveryPluginFactory::new()); - let session = host.build_session("root", None).expect("session"); - - assert!( - session - .tools() - .tool_manifests() - .iter() - .all(|manifest| manifest.name != "search_tools") - ); - assert!( - session - .collect_prompt_contributions(prompt_context()) - .await - .expect("prompt contributions") - .is_empty() - ); - } - - #[tokio::test] - async fn non_empty_deferred_catalog_installs_search_overlay_and_preview() { - let host = plugin_host(ToolDiscoveryPluginFactory::with_catalog(vec![ - catalog_record(), - ])); - let session = host.build_session("root", None).expect("session"); - - assert!( - session - .tools() - .tool_manifests() - .iter() - .any(|manifest| manifest.name == "search_tools") - ); - let contributions = session - .collect_prompt_contributions(prompt_context()) - .await - .expect("prompt contributions"); - assert_eq!(contributions.len(), 1); - assert_eq!( - contributions[0].title.as_deref(), - Some("Catalogued Capabilities") - ); - assert!(contributions[0].content.contains("appworld.venmo_send")); - } -} diff --git a/crates/lash-cli/src/examples/mcp_discovery/service.rs b/crates/lash-cli/src/examples/mcp_discovery/service.rs deleted file mode 100644 index e17fe7d..0000000 --- a/crates/lash-cli/src/examples/mcp_discovery/service.rs +++ /dev/null @@ -1,499 +0,0 @@ -use std::sync::{Arc, RwLock}; - -use lash_core::{ToolCall, ToolContext, ToolResult}; -use lash_tool_support::{StaticToolExecute, StaticToolProvider}; -use serde_json::{Value, json}; - -use super::common::{LLM_CANDIDATE_LIMIT, args_with_limit, catalog_key, limit_from_args}; -use super::definitions::search_tools_definition; -use super::ranking::ToolDiscoveryIndex; -use super::rerank::{ - llm_rerank_request, merge_llm_selection, parse_llm_tool_names, rerank_payment_action_intent, -}; - -#[derive(Clone, Default)] -struct IndexCache { - index: Option, -} - -#[derive(Clone)] -struct CachedIndex { - catalog_addr: usize, - catalog_len: usize, - index: Arc, -} - -impl CachedIndex { - fn matches_catalog_arc(&self, catalog: &Arc>) -> bool { - self.catalog_addr == Arc::as_ptr(catalog) as usize && self.catalog_len == catalog.len() - } -} - -#[derive(Clone)] -pub struct ToolDiscoveryToolsProvider { - cache: Arc>, - extra_catalog: Arc>, -} - -impl Default for ToolDiscoveryToolsProvider { - fn default() -> Self { - Self::new() - } -} - -impl ToolDiscoveryToolsProvider { - pub fn new() -> Self { - Self::with_catalog(Vec::new()) - } - - pub fn with_catalog(extra_catalog: Vec) -> Self { - Self { - cache: Arc::default(), - extra_catalog: Arc::new(extra_catalog), - } - } - - fn searchable_catalog(&self, resident_catalog: Arc>) -> Arc> { - if self.extra_catalog.is_empty() { - return resident_catalog; - } - let mut combined = Vec::with_capacity(resident_catalog.len() + self.extra_catalog.len()); - combined.extend(resident_catalog.iter().cloned()); - combined.extend(self.extra_catalog.iter().cloned()); - Arc::new(combined) - } - - fn index_for_catalog(&self, catalog: Arc>) -> Arc { - if let Some(index) = self - .cache - .read() - .expect("tool discovery cache lock poisoned") - .index - .as_ref() - .filter(|cached| cached.matches_catalog_arc(&catalog)) - .map(|cached| Arc::clone(&cached.index)) - { - return index; - } - - let key = catalog_key(catalog.as_ref()); - if let Some(index) = self - .cache - .read() - .expect("tool discovery cache lock poisoned") - .index - .as_ref() - .filter(|cached| cached.index.key == key) - .map(|cached| Arc::clone(&cached.index)) - { - return index; - } - - let index = Arc::new(ToolDiscoveryIndex::build(key, catalog.as_ref())); - let cached = CachedIndex { - catalog_addr: Arc::as_ptr(&catalog) as usize, - catalog_len: catalog.len(), - index: Arc::clone(&index), - }; - self.cache - .write() - .expect("tool discovery cache lock poisoned") - .index = Some(cached); - index - } - - async fn search_tools( - &self, - args: &Value, - resident_catalog: Arc>, - context: &ToolContext<'_>, - ) -> ToolResult { - let catalog = self.searchable_catalog(resident_catalog); - let index = self.index_for_catalog(catalog); - let limit = limit_from_args(args); - let candidate_args = args_with_limit(args, LLM_CANDIDATE_LIMIT); - let candidates = index.search(&candidate_args); - if candidates.is_empty() { - return ToolResult::ok(json!([])); - } - let query = args - .get("query") - .and_then(Value::as_str) - .unwrap_or_default(); - let candidates = rerank_payment_action_intent(query, candidates); - - let model = match context.sessions().model().await { - Ok(model) => model, - Err(err) => { - return ToolResult::err_fmt(format_args!( - "search_tools could not resolve parent model: {err}" - )); - } - }; - let request = llm_rerank_request( - args, - &candidates, - limit, - model.model, - crate::model_selection::variant_from_reasoning_selection(model.model_variant), - ); - let completion = match context - .direct_completions() - .complete(request, "search_tools") - .await - { - Ok(completion) => completion, - Err(err) => return ToolResult::err_fmt(format_args!("search_tools failed: {err}")), - }; - - let selected_names = match parse_llm_tool_names(&completion.text) { - Ok(names) => names, - Err(err) => { - return ToolResult::err_fmt(format_args!( - "search_tools returned invalid JSON: {err}" - )); - } - }; - - ToolResult::ok(json!(merge_llm_selection( - candidates, - selected_names, - limit - ))) - } -} - -/// Build the `search_tools` provider with an additional non-resident catalog. -pub fn tool_discovery_provider_with_catalog( - extra_catalog: Vec, -) -> StaticToolProvider { - StaticToolProvider::new( - vec![search_tools_definition()], - ToolDiscoveryToolsProvider::with_catalog(extra_catalog), - ) -} - -#[async_trait::async_trait] -impl StaticToolExecute for ToolDiscoveryToolsProvider { - async fn execute(&self, call: ToolCall<'_>) -> ToolResult { - match call.name { - "search_tools" => match call.context.sessions().shared_tool_catalog().await { - Ok(catalog) => self.search_tools(call.args, catalog, call.context).await, - Err(err) => ToolResult::err_fmt(err.to_string()), - }, - _ => ToolResult::err_fmt(format_args!("Unknown tool: {}", call.name)), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use lash_core::plugin::runtime_host::{ - SessionGraphService, SessionLifecycleService, SessionStateService, - }; - use lash_core::plugin::{PluginError, SessionHandle, SessionSnapshot}; - use lash_core::{ - DirectCompletion, TokenUsage, ToolCall, ToolContract, ToolDefinition, ToolProvider, - }; - use lash_tool_support::{LashlangToolBinding, ToolDefinitionLashlangExt}; - use serde_json::json; - use std::sync::Mutex; - - #[derive(Default)] - struct FakeSessionManager { - snapshot: SessionSnapshot, - catalog: Vec, - direct_response: Mutex>, - direct_requests: Mutex>, - } - - #[async_trait::async_trait] - impl SessionStateService for FakeSessionManager { - async fn snapshot_current(&self) -> Result { - Ok(self.snapshot.clone()) - } - - async fn snapshot_session( - &self, - _session_id: &str, - ) -> Result { - Ok(self.snapshot.clone()) - } - - async fn tool_catalog(&self, _session_id: &str) -> Result, PluginError> { - Ok(self.catalog.clone()) - } - - async fn set_tool_membership( - &self, - _session_id: &str, - _tool_names: &[String], - _present: bool, - ) -> Result { - Ok(0) - } - } - - #[async_trait::async_trait] - impl SessionLifecycleService for FakeSessionManager { - async fn create_session( - &self, - _request: lash_core::plugin::SessionCreateRequest, - ) -> Result { - Err(PluginError::Session("unused".to_string())) - } - - async fn close_session(&self, _session_id: &str) -> Result<(), PluginError> { - Ok(()) - } - } - - #[async_trait::async_trait] - impl SessionGraphService for FakeSessionManager {} - - fn snapshot_with_model(model: &str, variant: Option<&str>) -> SessionSnapshot { - let mut snapshot = SessionSnapshot::default(); - snapshot.policy.model.id = model.to_string(); - snapshot.policy.model.variant = - crate::model_selection::reasoning_selection_from_variant(variant.map(str::to_string)); - snapshot - } - - fn discovery_context(host: Arc) -> lash_core::ToolContext<'static> { - let direct_host = Arc::clone(&host); - lash_core::testing::mock_tool_context_with_host_and_direct_completions( - host, - lash_core::DirectCompletionClient::from_fn(move |request, _usage_source| { - direct_host - .direct_requests - .lock() - .expect("direct requests lock poisoned") - .push(request); - let text = direct_host - .direct_response - .lock() - .expect("direct response lock poisoned") - .clone() - .unwrap_or_else(|| "{\"tool_names\":[]}".to_string()); - Ok(DirectCompletion { - text, - usage: TokenUsage::default(), - llm_call: lash_core::LlmCallRecord { - call_id: lash_core::LlmCallId("mcp-discovery-test".to_string()), - label: None, - attempts: Vec::new(), - }, - }) - }), - ) - } - - fn catalog_tool_with_metadata( - name: &str, - description: &str, - module: Option<&str>, - aliases: Vec<&str>, - ) -> Value { - let tool = ToolDefinition::raw( - format!("tool:test/{name}"), - name, - description, - ToolContract::default_input_schema(), - json!({}), - ) - .with_lashlang_binding( - LashlangToolBinding::new( - [module.unwrap_or(match name { - "read_file" => "files", - "search_web" => "web", - _ => "tools", - })], - match name { - "read_file" => "read", - "search_web" => "search", - _ => name, - }, - ) - .with_aliases(aliases), - ); - let manifest = tool.manifest(); - // The flat catalog projection (see `project_tool_catalog` in - // lash-core) emits id/name/description/bindings/activation/contract; - // the example derives the call path from the `lashlang.tool` binding. - json!({ - "id": manifest.id, - "name": manifest.name, - "description": manifest.description, - "bindings": manifest.bindings, - "activation": manifest.activation, - "contract": manifest.compact_contract.clone().expect("compact contract"), - }) - } - - fn ranked_names(results: &[Value]) -> Vec { - results - .iter() - .map(|result| { - result - .get("name") - .and_then(Value::as_str) - .expect("ranked result name") - .to_string() - }) - .collect() - } - - #[test] - fn provider_exposes_search_tools_only() { - let manifests = tool_discovery_provider_with_catalog(Vec::new()).tool_manifests(); - let names = manifests - .iter() - .map(|definition| definition.name.as_str()) - .collect::>(); - - assert_eq!(names, vec!["search_tools"]); - #[cfg(not(feature = "lashlang"))] - assert!(manifests[0].bindings.is_empty()); - #[cfg(feature = "lashlang")] - assert!( - manifests[0] - .bindings - .contains_key(lash_lashlang_runtime::LASHLANG_TOOL_BINDING_KEY) - ); - } - - #[test] - fn index_cache_records_and_reuses_shared_catalog_identity() { - let provider = ToolDiscoveryToolsProvider::new(); - let catalog = Arc::new(vec![catalog_tool_with_metadata( - "read_file", - "Read file contents", - Some("filesystem"), - vec!["cat"], - )]); - - let first = provider.index_for_catalog(Arc::clone(&catalog)); - let second = provider.index_for_catalog(Arc::clone(&catalog)); - assert!(Arc::ptr_eq(&first, &second)); - - let cache = provider.cache.read().expect("cache lock"); - let cached = cache.index.as_ref().expect("cached index"); - assert_eq!(cached.catalog_addr, Arc::as_ptr(&catalog) as usize); - assert_eq!(cached.catalog_len, catalog.len()); - drop(cache); - - let same_content = Arc::new(catalog.as_ref().clone()); - let third = provider.index_for_catalog(same_content); - assert!(Arc::ptr_eq(&first, &third)); - } - - #[tokio::test] - async fn search_tools_uses_host_catalog_and_projects_compact_contract() { - let host = Arc::new(FakeSessionManager { - catalog: vec![ - catalog_tool_with_metadata( - "read_file", - "Read file contents", - Some("filesystem"), - vec!["cat"], - ), - catalog_tool_with_metadata( - "search_web", - "Search the web", - Some("web"), - vec!["web_search"], - ), - ], - ..Default::default() - }); - let provider = tool_discovery_provider_with_catalog(Vec::new()); - let context = discovery_context(host); - - #[cfg(feature = "lashlang")] - let args = json!({ - "query": "cat", - "module": "filesystem", - "limit": 1, - }); - #[cfg(not(feature = "lashlang"))] - let args = json!({ - "query": "read file", - "limit": 1, - }); - let result = provider - .execute(ToolCall { - name: "search_tools", - args: &args, - context: &context, - progress: None, - }) - .await; - - assert!(result.is_success(), "{result:?}"); - let value = result.value_for_projection(); - let results = value.as_array().expect("search result list"); - assert_eq!(results.len(), 1); - assert_eq!(results[0]["id"], json!("tool:test/read_file")); - assert_eq!(results[0]["name"], json!("read_file")); - #[cfg(feature = "lashlang")] - assert_eq!(results[0]["call"], json!("filesystem.read")); - #[cfg(not(feature = "lashlang"))] - assert!(results[0].get("call").is_none()); - assert!( - results[0]["signature"] - .as_str() - .expect("signature") - .starts_with("read_file({})") - ); - assert_eq!(results[0]["description"], json!("Read file contents")); - #[cfg(feature = "lashlang")] - assert_eq!(results[0]["module_path"], json!(["filesystem"])); - #[cfg(not(feature = "lashlang"))] - assert!(results[0].get("module_path").is_none()); - assert!(results[0].get("score").is_none()); - } - - #[tokio::test] - async fn search_tools_reranks_candidates_with_direct_completion() { - let host = Arc::new(FakeSessionManager { - snapshot: snapshot_with_model("gpt-5.5", Some("medium")), - catalog: vec![ - catalog_tool_with_metadata("read_file", "Read file contents", None, vec!["cat"]), - catalog_tool_with_metadata("search_web", "Search the web", None, vec!["web"]), - ], - direct_response: Mutex::new(Some( - "{\"tool_names\":[\"search_web\",\"search_web\",\"unknown\"]}".to_string(), - )), - ..Default::default() - }); - let provider = tool_discovery_provider_with_catalog(Vec::new()); - let context = discovery_context(host.clone()); - - let args = json!({ - "query": "", - "exclude": ["read_file"], - "limit": 2, - }); - let result = provider - .execute(ToolCall { - name: "search_tools", - args: &args, - context: &context, - progress: None, - }) - .await; - - assert!(result.is_success(), "{result:?}"); - let value = result.value_for_projection(); - let results = value.as_array().expect("search result list"); - assert_eq!(ranked_names(results), vec!["search_web"]); - let requests = host - .direct_requests - .lock() - .expect("direct requests lock poisoned"); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].model, "gpt-5.5"); - assert_eq!(requests[0].model_variant.effort(), Some("medium")); - } -} diff --git a/crates/lash-cli/src/examples/mod.rs b/crates/lash-cli/src/examples/mod.rs deleted file mode 100644 index ed72837..0000000 --- a/crates/lash-cli/src/examples/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! Reference examples that demonstrate host policy on top of the lash runtime, -//! kept in the CLI rather than shipped as lash primitives. - -pub mod mcp_discovery; diff --git a/crates/lash-cli/src/execution_settings.rs b/crates/lash-cli/src/execution_settings.rs index 5e5c881..85d02da 100644 --- a/crates/lash-cli/src/execution_settings.rs +++ b/crates/lash-cli/src/execution_settings.rs @@ -1,33 +1,33 @@ -//! Execution-mode, RLM termination, and standard-context-approach settings: -//! parsing, labels, and the observational-memory tuning-flag overrides. +//! Host-owned execution-mode and RLM dialect settings. -use lash_standard_plugins::{StandardContextApproach, StandardContextApproachKind}; +pub use lash_rlm_types::RlmDialect; -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum ExecutionMode { +pub enum ExecutionMode { + #[default] Standard, Rlm, } impl ExecutionMode { - pub(crate) fn as_str(self) -> &'static str { + pub const fn as_str(self) -> &'static str { match self { Self::Standard => "standard", Self::Rlm => "rlm", } } - pub(crate) fn is_standard(self) -> bool { + pub const fn is_standard(self) -> bool { matches!(self, Self::Standard) } - pub(crate) fn is_rlm(self) -> bool { + pub const fn is_rlm(self) -> bool { matches!(self, Self::Rlm) } } -pub(crate) fn parse_execution_mode(input: &str) -> Result { +pub fn parse_execution_mode(input: &str) -> Result { match input.trim().to_ascii_lowercase().as_str() { "" => Err("Execution mode cannot be empty.".to_string()), "rlm" => Ok(ExecutionMode::Rlm), @@ -38,11 +38,21 @@ pub(crate) fn parse_execution_mode(input: &str) -> Result } } +pub fn parse_rlm_dialect(input: &str) -> Result { + let language_id = input.trim().to_ascii_lowercase(); + RlmDialect::from_language_id(&language_id).ok_or_else(|| { + format!( + "Unknown RLM dialect `{input}`. Expected {}.", + RlmDialect::registered_language_ids() + ) + }) +} + #[derive( Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum, serde::Serialize, serde::Deserialize, )] #[serde(rename_all = "snake_case")] -pub(crate) enum RlmTerminationMode { +pub enum RlmTerminationMode { #[value(name = "natural")] Natural, #[value(name = "finish-required")] @@ -50,7 +60,7 @@ pub(crate) enum RlmTerminationMode { } impl RlmTerminationMode { - pub(crate) fn as_rlm_termination(self) -> lash_rlm_types::RlmTermination { + pub fn as_rlm_termination(self) -> lash_rlm_types::RlmTermination { match self { Self::Natural => lash_rlm_types::RlmTermination::Natural, Self::FinishRequired => lash_rlm_types::RlmTermination::FinishRequired { schema: None }, @@ -58,215 +68,33 @@ impl RlmTerminationMode { } } -pub(crate) fn default_rlm_termination_for_mode(mode: ExecutionMode) -> Option { +pub fn default_rlm_termination_for_mode(mode: ExecutionMode) -> Option { mode.is_rlm().then_some(RlmTerminationMode::Natural) } -pub(crate) fn parse_standard_context_approach( - input: &str, -) -> Result { - match input.trim().to_ascii_lowercase().as_str() { - "" => Err("Context approach cannot be empty.".to_string()), - "rolling" | "rolling-history" | "rolling_history" => { - Ok(StandardContextApproach::RollingHistory(Default::default())) - } - "om" | "observational" | "observational-memory" | "observational_memory" => Ok( - StandardContextApproach::ObservationalMemory(Default::default()), - ), - other => Err(format!( - "Unknown context approach `{other}`. Expected `rolling_history` or `observational_memory`." - )), - } -} - -/// The `--om-*` observational-memory tuning flags, gathered once from the CLI -/// arguments so "are any set?" and "apply them" share a single source. -#[derive(Clone, Copy, Debug, Default)] -pub(crate) struct OmOverrides { - pub(crate) observation_message_tokens: Option, - pub(crate) observation_buffer_tokens: Option, - pub(crate) observation_block_after_tokens: Option, - pub(crate) observation_max_tokens_per_batch: Option, - pub(crate) previous_observer_tokens: Option, - pub(crate) reflection_observation_tokens: Option, - pub(crate) reflection_buffer_activation_percent: Option, - pub(crate) reflection_block_after_tokens: Option, -} - -impl OmOverrides { - pub(crate) fn from_args(args: &crate::Args) -> Self { - Self { - observation_message_tokens: args.om_observation_message_tokens, - observation_buffer_tokens: args.om_observation_buffer_tokens, - observation_block_after_tokens: args.om_observation_block_after_tokens, - observation_max_tokens_per_batch: args.om_observation_max_tokens_per_batch, - previous_observer_tokens: args.om_previous_observer_tokens, - reflection_observation_tokens: args.om_reflection_observation_tokens, - reflection_buffer_activation_percent: args.om_reflection_buffer_activation_percent, - reflection_block_after_tokens: args.om_reflection_block_after_tokens, - } - } - - pub(crate) fn is_empty(&self) -> bool { - self.observation_message_tokens.is_none() - && self.observation_buffer_tokens.is_none() - && self.observation_block_after_tokens.is_none() - && self.observation_max_tokens_per_batch.is_none() - && self.previous_observer_tokens.is_none() - && self.reflection_observation_tokens.is_none() - && self.reflection_buffer_activation_percent.is_none() - && self.reflection_block_after_tokens.is_none() - } - - pub(crate) fn apply( - self, - mut approach: StandardContextApproach, - ) -> Result { - if self.is_empty() { - return Ok(approach); - } - - let StandardContextApproach::ObservationalMemory(config) = &mut approach else { - return Err( - "OM tuning flags require `--context-approach observational_memory`.".to_string(), - ); - }; - - if let Some(value) = self.observation_message_tokens { - if value == 0 { - return Err("`--om-observation-message-tokens` must be greater than 0.".to_string()); - } - config.observation_message_tokens = value; - } - if let Some(value) = self.observation_buffer_tokens { - config.observation_buffer_tokens = value; - } - if let Some(value) = self.observation_block_after_tokens { - if value == 0 { - return Err( - "`--om-observation-block-after-tokens` must be greater than 0.".to_string(), - ); - } - config.observation_block_after_tokens = value; - } - if let Some(value) = self.observation_max_tokens_per_batch { - if value == 0 { - return Err( - "`--om-observation-max-tokens-per-batch` must be greater than 0.".to_string(), - ); - } - config.observation_max_tokens_per_batch = value; - } - if let Some(value) = self.previous_observer_tokens { - config.previous_observer_tokens = value; - } - if let Some(value) = self.reflection_observation_tokens { - if value == 0 { - return Err( - "`--om-reflection-observation-tokens` must be greater than 0.".to_string(), - ); - } - config.reflection_observation_tokens = value; - } - if let Some(value) = self.reflection_buffer_activation_percent { - if value > 100 { - return Err( - "`--om-reflection-buffer-activation-percent` must be between 0 and 100." - .to_string(), - ); - } - config.reflection_buffer_activation_bps = value.saturating_mul(100); - } - if let Some(value) = self.reflection_block_after_tokens { - if value == 0 { - return Err( - "`--om-reflection-block-after-tokens` must be greater than 0.".to_string(), - ); - } - config.reflection_block_after_tokens = value; - } - - if config.observation_buffer_tokens >= config.observation_block_after_tokens { - return Err( - "`--om-observation-buffer-tokens` must be smaller than `--om-observation-block-after-tokens`." - .to_string(), - ); - } - if config.observation_buffer_interval_tokens() >= config.observation_message_tokens { - return Err( - "`--om-observation-buffer-tokens` must be smaller than `--om-observation-message-tokens`." - .to_string(), - ); - } - if config.reflection_buffer_activation_bps > 10_000 { - return Err( - "`--om-reflection-buffer-activation-percent` must be between 0 and 100." - .to_string(), - ); - } - - Ok(approach) - } -} - -pub(crate) fn execution_mode_usage() -> &'static str { +pub const fn execution_mode_usage() -> &'static str { "" } -pub(crate) fn ensure_supported_execution_mode( - mode: ExecutionMode, -) -> Result { +pub fn ensure_supported_execution_mode(mode: ExecutionMode) -> Result { Ok(mode) } -pub(crate) fn execution_mode_label(mode: &ExecutionMode) -> &str { +pub const fn execution_mode_label(mode: &ExecutionMode) -> &str { mode.as_str() } -pub(crate) fn standard_context_approach_label(approach: &StandardContextApproach) -> &'static str { - match approach.kind() { - StandardContextApproachKind::RollingHistory => "rolling_history", - StandardContextApproachKind::ObservationalMemory => "observational_memory", - } -} - #[cfg(test)] mod tests { use super::*; #[test] - fn observational_memory_overrides_apply_to_standard_context_approach() { - let approach = OmOverrides { - observation_message_tokens: Some(45_000), - observation_buffer_tokens: Some(8_000), - observation_max_tokens_per_batch: Some(12_000), - previous_observer_tokens: Some(3_000), - reflection_buffer_activation_percent: Some(60), - ..OmOverrides::default() - } - .apply(StandardContextApproach::ObservationalMemory( - Default::default(), - )) - .expect("overrides"); - let StandardContextApproach::ObservationalMemory(config) = approach else { - panic!("expected observational_memory"); - }; - assert_eq!(config.observation_message_tokens, 45_000); - assert_eq!(config.observation_buffer_tokens, 8_000); - assert_eq!(config.observation_max_tokens_per_batch, 12_000); - assert_eq!(config.previous_observer_tokens, 3_000); - assert_eq!(config.reflection_buffer_activation_bps, 6_000); - } - - #[test] - fn observational_memory_overrides_require_om_standard_context_approach() { - let err = OmOverrides { - observation_message_tokens: Some(45_000), - ..OmOverrides::default() - } - .apply(StandardContextApproach::RollingHistory(Default::default())) - .expect_err("expected validation error"); - assert!(err.contains("observational_memory")); + fn parses_registered_rlm_dialects() { + assert_eq!(parse_rlm_dialect("lashlang"), Ok(RlmDialect::Lashlang)); + assert_eq!(parse_rlm_dialect("typescript"), Ok(RlmDialect::Typescript)); + let error = parse_rlm_dialect("python").expect_err("unregistered dialect"); + assert!(error.contains("`lashlang`")); + assert!(error.contains("`typescript`")); } #[test] @@ -280,16 +108,4 @@ mod tests { lash_rlm_types::RlmTermination::FinishRequired { schema: None } )); } - - #[test] - fn rlm_termination_defaults_only_for_rlm_mode() { - assert_eq!( - default_rlm_termination_for_mode(ExecutionMode::Rlm), - Some(RlmTerminationMode::Natural) - ); - assert_eq!( - default_rlm_termination_for_mode(ExecutionMode::Standard), - None - ); - } } diff --git a/crates/lash-cli/src/fork.rs b/crates/lash-cli/src/fork.rs index 4860b1c..eac5db9 100644 --- a/crates/lash-cli/src/fork.rs +++ b/crates/lash-cli/src/fork.rs @@ -1,25 +1,8 @@ use std::path::{Path, PathBuf}; use std::process::Stdio; -use anyhow::{Context, Result, anyhow}; -use lash_core::provider::ProviderHandle; -use lash_core::store::SessionHead; -use lash_sqlite_store::Store; - -use crate::persistence::persist_committed_runtime_state; use crate::session_log::SessionLogger; -use crate::startup::session::SessionBootstrap; - -async fn persist_parent_root_snapshot(session: &lash::LashSession, store: &Store) -> Result<()> { - let mut state = session - .admin() - .state() - .persist_current() - .await - .context("Failed to snapshot session state for fork")?; - persist_committed_runtime_state(store, &mut state).await; - Ok(()) -} +use anyhow::{Context, Result, anyhow}; fn find_program_on_path(name: &str) -> Option { let path = std::env::var_os("PATH")?; @@ -44,7 +27,6 @@ fn is_launchable_file(path: &Path) -> bool { true } } - pub fn resolve_resume_executable() -> Result { if std::env::var_os("LASH_DEV_LAUNCH_CWD").is_some() && let Some(exe) = find_program_on_path("lash") @@ -444,44 +426,6 @@ fn spawn_in_new_terminal_platform(exe: &Path, args: &[String]) -> Result<()> { )) } -#[cfg(test)] -mod tests { - use super::{DetectedTerminal, detect_terminal_from_hints}; - - #[test] - fn detect_terminal_prefers_term_program() { - assert_eq!( - detect_terminal_from_hints(Some("ghostty"), true, true, true), - Some(DetectedTerminal::Ghostty) - ); - assert_eq!( - detect_terminal_from_hints(Some("Apple_Terminal"), false, false, false), - Some(DetectedTerminal::AppleTerminal) - ); - assert_eq!( - detect_terminal_from_hints(Some("iTerm.app"), false, false, false), - Some(DetectedTerminal::ITerm2) - ); - } - - #[test] - fn detect_terminal_falls_back_to_terminal_specific_envs() { - assert_eq!( - detect_terminal_from_hints(None, true, false, false), - Some(DetectedTerminal::Kitty) - ); - assert_eq!( - detect_terminal_from_hints(None, false, true, false), - Some(DetectedTerminal::WezTerm) - ); - assert_eq!( - detect_terminal_from_hints(None, false, false, true), - Some(DetectedTerminal::ITerm2) - ); - assert_eq!(detect_terminal_from_hints(None, false, false, false), None); - } -} - #[cfg(target_os = "windows")] fn powershell_quote(value: &str) -> String { format!("'{}'", value.replace('\'', "''")) @@ -547,43 +491,6 @@ pub fn spawn_in_new_terminal(exe: &Path, args: &[String]) -> Result<()> { spawn_in_new_terminal_platform(exe, args) } -#[allow(clippy::too_many_arguments)] -async fn materialize_child_from_graph( - child_session_id: &str, - child_store: &Store, - parent_store: &Store, - graph: &lash_core::SessionGraph, - config: &lash_core::PersistedSessionConfig, - checkpoint_ref: Option<&lash_core::BlobRef>, -) { - let child_graph = graph.fork_current_path(); - let child_checkpoint_ref = if let Some(blob_ref) = checkpoint_ref { - match parent_store.get_checkpoint(blob_ref).await { - Some(checkpoint) => Some(child_store.put_checkpoint(&checkpoint).await.checkpoint_ref), - None => None, - } - } else { - None - }; - let token_ledger = parent_store - .load_session_head() - .await - .map(|head| head.token_ledger) - .unwrap_or_default(); - child_store - .save_session_head(SessionHead { - session_id: child_session_id.to_string(), - head_revision: 0, - agent_frames: Vec::new(), - current_agent_frame_id: String::new(), - graph: child_graph.clone(), - config: config.clone(), - checkpoint_ref: child_checkpoint_ref.clone(), - token_ledger, - }) - .await; -} - pub struct ForkedSession { pub session_id: String, pub session_name: String, @@ -591,243 +498,83 @@ pub struct ForkedSession { pub async fn fork_current_session( session: Option<&lash::LashSession>, + opener: &crate::startup::session::CliSessionOpener, logger: &SessionLogger, - _provider: &ProviderHandle, configured_model: &str, - _context_window: u64, - _model_variant: Option<&str>, ) -> Result { - if let Some(session) = session { - persist_parent_root_snapshot(session, logger.store().as_ref()).await?; - } - let child_bootstrap = - SessionBootstrap::fork_child(&logger.session_id, configured_model).await?; - let child_store = child_bootstrap.store(); - let child_meta = child_store - .load_session_meta() + let session = session.ok_or_else(|| anyhow!("No active session to fork"))?; + let node_id = session + .read_view() + .session_graph() + .leaf_node_id + .clone() + .ok_or_else(|| anyhow!("The session has no committed node to fork"))?; + let child_session_id = uuid::Uuid::new_v4().to_string(); + opener + .fork_at(&node_id, &child_session_id) .await - .ok_or_else(|| anyhow!("Fork child session metadata was not created"))?; - let parent_head = if let Some(head) = logger.store().load_session_head().await { - head - } else { - let model = lash_core::ModelSpec::from_token_limits( - configured_model, - _model_variant - .map(|effort| lash_core::ReasoningSelection::Effort(effort.to_string())) - .unwrap_or_default(), - usize::try_from(_context_window).context("fork context window does not fit usize")?, - None, - ) - .map_err(anyhow::Error::msg)?; - SessionHead { - session_id: child_meta.session_id.clone(), - head_revision: 0, - agent_frames: Vec::new(), - current_agent_frame_id: String::new(), - graph: lash_core::SessionGraph::default(), - config: lash_core::PersistedSessionConfig { - provider_id: _provider.kind().to_string(), - model, - }, - checkpoint_ref: None, - token_ledger: Vec::new(), - } - }; - materialize_child_from_graph( - &child_meta.session_id, - child_store.as_ref(), - logger.store().as_ref(), - &parent_head.graph, - &parent_head.config, - parent_head.checkpoint_ref.as_ref(), - ) - .await; - + .context("fork session through Lash")?; + let child_session_name = + crate::generate_session_name(&crate::session_log::sessions_dir()).await; + crate::session_log::save_roster_entry(&crate::session_log::RosterEntry { + session_id: child_session_id.clone(), + session_name: child_session_name.clone(), + model: configured_model.to_string(), + created_at: chrono::Local::now().to_rfc3339(), + cwd: std::env::current_dir().ok(), + parent_session_id: Some(logger.session_id.clone()), + message_count: session + .read_view() + .messages() + .iter() + .filter(|message| message.role == lash_core::MessageRole::User) + .count(), + first_message: String::new(), + inputs: Vec::new(), + })?; + if let Ok(config) = crate::session_log::load_host_config(&logger.session_id) { + crate::session_log::save_host_config(&child_session_id, &config)?; + } Ok(ForkedSession { - session_id: child_meta.session_id, - session_name: child_meta.session_name, + session_id: child_session_id, + session_name: child_session_name, }) } #[cfg(test)] -mod fork_tests { - use super::*; - use crate::session_log; - use crate::test_support::{EnvVarGuard, TempDirGuard, env_lock}; - use lash_core::ToolState; - use lash_core::provider::ProviderHandle; - use std::sync::Arc; - - fn dummy_provider() -> ProviderHandle { - ProviderHandle::new( - lash_provider_openai::OpenAiCompatibleProvider::new( - "test", - "https://example.invalid/v1", - ) - .into_components(), - ) - } - - fn empty_tool_state() -> ToolState { - ToolState::default() - } - - fn persisted_graph( - messages: Vec, - _iteration: usize, - ) -> lash_core::SessionGraph { - lash_core::SessionGraph::from_active_read_state(&messages) - } - - fn persisted_checkpoint(iteration: usize) -> lash_core::store::HydratedSessionCheckpoint { - lash_core::store::HydratedSessionCheckpoint { - turn_state: lash_core::PersistedTurnState { - turn_index: iteration, - token_usage: lash_core::TokenUsage { - input_tokens: 10, - output_tokens: 3, - cache_read_input_tokens: 1, - cache_write_input_tokens: 0, - reasoning_output_tokens: 2, - }, - last_prompt_usage: None, - protocol_turn_options: Default::default(), - }, - tool_state_ref: None, - tool_state: Some(empty_tool_state()), - plugin_snapshot_ref: None, - plugin_snapshot_revision: None, - plugin_snapshot: None, - execution_state_ref: None, - execution_state: None, - } - } - - async fn save_persisted_root(store: &Store, graph: lash_core::SessionGraph, iteration: usize) { - let checkpoint_ref = store - .put_checkpoint(&persisted_checkpoint(iteration)) - .await - .checkpoint_ref; - store - .save_session_head(lash_core::store::SessionHead { - session_id: "root".to_string(), - head_revision: 0, - agent_frames: Vec::new(), - current_agent_frame_id: String::new(), - graph, - config: lash_core::PersistedSessionConfig { - provider_id: dummy_provider().kind().to_string(), - model: lash_core::ModelSpec::from_token_limits( - "gpt-test", - Default::default(), - 1024, - None, - ) - .expect("valid model spec"), - }, - checkpoint_ref: Some(checkpoint_ref), - token_ledger: Vec::new(), - }) - .await; - } - - #[tokio::test] - async fn resume_executable_prefers_dev_wrapper_when_present() { - let _env_guard = env_lock().lock().await; - let temp = TempDirGuard::new("lash-fork-path"); - let lash_bin = temp.path().join("lash"); - std::fs::write(&lash_bin, "#!/usr/bin/env sh\nexit 0\n").expect("write lash wrapper"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&lash_bin, std::fs::Permissions::from_mode(0o755)) - .expect("chmod lash wrapper"); - } - let _path = EnvVarGuard::set("PATH", temp.path()); - let _dev_cwd = EnvVarGuard::set("LASH_DEV_LAUNCH_CWD", temp.path()); +mod tests { + use super::{DetectedTerminal, detect_terminal_from_hints}; + #[test] + fn detect_terminal_prefers_term_program() { + assert_eq!( + detect_terminal_from_hints(Some("ghostty"), true, true, true), + Some(DetectedTerminal::Ghostty) + ); assert_eq!( - resolve_resume_executable().expect("resolve executable"), - lash_bin + detect_terminal_from_hints(Some("Apple_Terminal"), false, false, false), + Some(DetectedTerminal::AppleTerminal) + ); + assert_eq!( + detect_terminal_from_hints(Some("iTerm.app"), false, false, false), + Some(DetectedTerminal::ITerm2) ); } - #[tokio::test] - async fn fork_clones_persisted_root_snapshot_without_runtime_when_no_live_snapshot() { - let _env_guard = env_lock().lock().await; - let temp = TempDirGuard::new("lash-fork-persisted-snapshot"); - let _lash_home = EnvVarGuard::set("LASH_HOME", temp.path()); - std::fs::create_dir_all(session_log::sessions_dir()).expect("sessions dir"); - - let parent_filename = "parent.db".to_string(); - let parent_path = session_log::sessions_dir().join(&parent_filename); - let parent_store = Arc::new(Store::open(&parent_path).await.expect("parent store")); - let parent_logger = SessionLogger::new( - Arc::clone(&parent_store), - parent_filename.clone(), - "gpt-test", - Some("parent-session".into()), - "parent".into(), - ) - .await - .expect("parent logger"); - let messages = vec![lash_core::Message { - id: "u1".to_string(), - role: lash_core::MessageRole::User, - parts: vec![lash_core::Part { - id: "u1.p0".to_string(), - kind: lash_core::PartKind::Text, - content: "hello".to_string(), - attachment: None, - tool_call_id: None, - tool_name: None, - tool_replay: None, - prune_state: lash_core::PruneState::Intact, - reasoning_meta: None, - response_meta: None, - }] - .into(), - origin: None, - }]; - save_persisted_root(parent_store.as_ref(), persisted_graph(messages, 1), 1).await; - - let child = fork_current_session( - None, - &parent_logger, - &dummy_provider(), - "gpt-test", - 1024, - None, - ) - .await - .expect("fork should succeed"); - - let child_filename = session_log::filename_for_session_identifier(&child.session_id) - .await - .expect("child filename"); - let child_store = Store::open(&session_log::sessions_dir().join(child_filename)) - .await - .expect("child store"); - let child_meta = child_store.load_session_meta().await.expect("child meta"); - assert_eq!(child.session_id, child_meta.session_id); - assert_eq!(child.session_name, child_meta.session_name); - assert_eq!(child_meta.parent_session_id(), Some("parent-session")); - - let child_head = child_store.load_session_head().await.expect("child head"); - let child_graph = child_head.graph; - let child_checkpoint = match child_head.checkpoint_ref.as_ref() { - Some(blob_ref) => child_store.get_checkpoint(blob_ref).await, - None => None, - }; - let child_turn = child_checkpoint.expect("child checkpoint").turn_state; - assert_eq!(child_turn.turn_index, 1); - - let child_state = lash_core::SessionSnapshot { - session_graph: child_graph, - ..lash_core::SessionSnapshot::default() - }; - let child_view = lash_core::SessionReadView::from_snapshot(&child_state); - assert_eq!(child_view.messages().len(), 1); - assert_eq!(child_view.messages()[0].parts[0].content, "hello"); + #[test] + fn detect_terminal_falls_back_to_terminal_specific_envs() { + assert_eq!( + detect_terminal_from_hints(None, true, false, false), + Some(DetectedTerminal::Kitty) + ); + assert_eq!( + detect_terminal_from_hints(None, false, true, false), + Some(DetectedTerminal::WezTerm) + ); + assert_eq!( + detect_terminal_from_hints(None, false, false, true), + Some(DetectedTerminal::ITerm2) + ); + assert_eq!(detect_terminal_from_hints(None, false, false, false), None); } } diff --git a/crates/lash-cli/src/host_docs.rs b/crates/lash-cli/src/host_docs.rs index c35cf2d..7afbf90 100644 --- a/crates/lash-cli/src/host_docs.rs +++ b/crates/lash-cli/src/host_docs.rs @@ -3,10 +3,8 @@ use std::io; use std::path::{Path, PathBuf}; use std::sync::Arc; -use lash_core::{ - PluginError, PluginFactory, PluginRegistrar, PluginSessionContext, PromptContribution, - SessionPlugin, -}; +use lash::plugins::{PluginFactory, PluginRegistrar, PluginSessionContext, SessionPlugin}; +use lash_core::{PluginError, PromptContribution}; const DOCS_DIR: &str = "docs"; const HOST_DOCS_DIR: &str = "lash-cli"; @@ -190,77 +188,3 @@ fn host_docs_prompt_content(docs_dir: &Path) -> String { docs_dir.display() ) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ensure_host_docs_writes_managed_markdown() { - let temp = tempfile::TempDir::new().expect("temp dir"); - - let docs = ensure_host_docs_at(temp.path(), "9.9.9").expect("host docs"); - - assert_eq!(docs.dir(), temp.path().join("docs").join("lash-cli")); - assert!(docs.dir().join("README.md").exists()); - assert!(docs.dir().join("skills.md").exists()); - assert!(docs.dir().join("config.md").exists()); - assert!(docs.dir().join("sessions.md").exists()); - assert!(docs.dir().join("troubleshooting.md").exists()); - assert_eq!( - fs::read_to_string(docs.dir().join("VERSION")).expect("version"), - "lash-cli 9.9.9\n" - ); - assert!( - fs::read_to_string(docs.dir().join("skills.md")) - .expect("skills docs") - .contains("$LASH_HOME/skills") - ); - } - - #[test] - fn prompt_content_points_at_docs_dir() { - let docs_dir = PathBuf::from("/tmp/lash-home/docs/lash-cli"); - - let content = host_docs_prompt_content(&docs_dir); - - assert!(content.contains("/tmp/lash-home/docs/lash-cli")); - assert!(content.contains("read the relevant markdown file")); - assert!(content.contains("override general model memory")); - } - - #[tokio::test] - async fn plugin_contributes_docs_prompt() { - let docs_dir = PathBuf::from("/tmp/lash-home/docs/lash-cli"); - let plugin_host = lash_core::PluginHost::new(vec![ - Arc::new(lash_plugin_process_controls::SessionProcessAdminPluginFactory::new()), - Arc::new(lash_protocol_standard::StandardProtocolPluginFactory), - Arc::new(HostDocsPluginFactory::new(docs_dir.clone())), - ]); - let session = plugin_host.build_session("root", None).expect("session"); - - let contributions = session - .collect_prompt_contributions(lash_core::PromptHookContext { - session_id: "root".to_string(), - sessions: Arc::new(lash_core::testing::MockSessionManager::default()), - state: lash_core::SessionReadView::from_snapshot( - &lash_core::SessionSnapshot::default(), - ), - protocol_turn_options: lash_core::ProtocolTurnOptions::default(), - turn_context: lash_core::TurnContext::default(), - }) - .await - .expect("prompt contributions"); - - let contribution = contributions - .iter() - .find(|contribution| contribution.title.as_deref() == Some("Lash CLI Host Docs")) - .expect("host docs contribution"); - assert_eq!(contribution.slot, lash_core::PromptSlot::Environment); - assert!( - contribution - .content - .contains(&docs_dir.display().to_string()) - ); - } -} diff --git a/crates/lash-cli/src/host_policy.rs b/crates/lash-cli/src/host_policy.rs new file mode 100644 index 0000000..103866a --- /dev/null +++ b/crates/lash-cli/src/host_policy.rs @@ -0,0 +1,51 @@ +//! Explicit operational policy chosen by the lash CLI host. + +/// Maximum protocol steps one user turn may execute. +pub(crate) const TURN_BUDGET: usize = 128; + +/// Maximum consecutive provider attempts that make no durable progress. +pub(crate) const NO_PROGRESS_BUDGET: usize = 8; + +/// Maximum logical bytes and graph rows accepted by one atomic runtime commit. +pub(crate) const COMMIT_BUDGET: (usize, usize) = (1024 * 1024, 512); + +/// Model-action token reserve used when Lash batches queued work. +pub(crate) const QUEUED_WORK_BATCHING: usize = 1024; + +/// Finite RLM VM limits, spanning instructions, wall time, and logical bytes. +pub(crate) const RLM_EXECUTION_BOUNDS: RlmExecutionBounds = RlmExecutionBounds { + instructions: 1_000_000, + wall_clock_seconds: 30, + logical_bytes: 64 * 1024 * 1024, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct RlmExecutionBounds { + pub(crate) instructions: u64, + pub(crate) wall_clock_seconds: u64, + pub(crate) logical_bytes: u64, +} + +pub(crate) fn turn_budget() -> lash::TurnBudget { + lash::TurnBudget::bounded(TURN_BUDGET) +} + +pub(crate) fn no_progress_budget() -> lash::NoProgressBudget { + lash::NoProgressBudget::bounded(NO_PROGRESS_BUDGET) +} + +pub(crate) fn commit_budget() -> lash::CommitBudget { + lash::CommitBudget::bounded(COMMIT_BUDGET.0, COMMIT_BUDGET.1) +} + +pub(crate) fn queued_work_batching() -> lash::QueuedWorkBatchingConfig { + lash::QueuedWorkBatchingConfig::new(QUEUED_WORK_BATCHING) +} + +pub(crate) fn rlm_protocol_config() -> lash::rlm::RlmProtocolPluginConfig { + lash::rlm::RlmProtocolPluginConfig::new( + lash::rlm::ExecutionBound::instructions(RLM_EXECUTION_BOUNDS.instructions), + lash::rlm::ExecutionBound::secs(RLM_EXECUTION_BOUNDS.wall_clock_seconds), + lash::rlm::ExecutionBound::instructions(RLM_EXECUTION_BOUNDS.logical_bytes), + ) +} diff --git a/crates/lash-cli/src/info.rs b/crates/lash-cli/src/info.rs index f858004..e0a0abe 100644 --- a/crates/lash-cli/src/info.rs +++ b/crates/lash-cli/src/info.rs @@ -1,13 +1,12 @@ //! Version/info text and the Info/Help/Controls document overlays. use lash::provider::ProviderHandle; -use lash_standard_plugins::StandardContextApproach; use lash_tui_extensions::TuiExtensions; use crate::SkillCatalog; use crate::command; use crate::execution_settings::{ - ExecutionMode, execution_mode_label, execution_mode_usage, standard_context_approach_label, + ExecutionMode, RlmDialect, execution_mode_label, execution_mode_usage, }; use crate::keybindings::shortcut_help_rows; use crate::model_selection::provider_display_label; @@ -16,16 +15,24 @@ use crate::overlay::{DocumentRow, DocumentSection, DocumentState}; pub(crate) fn version_text() -> String { format!( "lash-cli {} -lash-sansio {}", +durable formats: {}", crate::APP_VERSION, - lash_core::SANSIO_VERSION + durable_formats_text() ) } +fn durable_formats_text() -> String { + lash::formats::durable_formats() + .iter() + .map(|entry| format!("{}={}", entry.format.name(), entry.version)) + .collect::>() + .join(", ") +} + pub(crate) fn info_text_unconfigured(execution_mode: &ExecutionMode, cwd: &str) -> String { [ format!("lash-cli: {}", crate::APP_VERSION), - format!("lash-sansio: {}", lash_core::SANSIO_VERSION), + format!("durable formats: {}", durable_formats_text()), "provider: (not configured)".to_string(), "configured model: (not configured)".to_string(), "resolved model: (not configured)".to_string(), @@ -43,7 +50,7 @@ pub(crate) fn info_text( configured_model: &str, model_variant: Option<&str>, execution_mode: &ExecutionMode, - standard_context_approach: Option<&StandardContextApproach>, + rlm_dialect: Option, context_window: Option, tool_summary: Option<(usize, &str)>, cwd: &str, @@ -55,7 +62,7 @@ pub(crate) fn info_text( crate::provider_metadata::provider_wire_model_id(provider.kind(), configured_model); let mut lines = vec![ format!("lash-cli: {}", crate::APP_VERSION), - format!("lash-sansio: {}", lash_core::SANSIO_VERSION), + format!("durable formats: {}", durable_formats_text()), format!( "provider: {} ({})", provider_display_label(provider), @@ -65,13 +72,8 @@ pub(crate) fn info_text( format!("resolved model: {}", resolved_model), format!("execution mode: {}", execution_mode_label(execution_mode)), ]; - if *execution_mode == ExecutionMode::Standard - && let Some(standard_context_approach) = standard_context_approach - { - lines.push(format!( - "context approach: {}", - standard_context_approach_label(standard_context_approach) - )); + if let Some(dialect) = rlm_dialect { + lines.push(format!("rlm dialect: {}", dialect.language_id())); } if let Some(variant) = model_variant { @@ -108,7 +110,7 @@ pub(crate) fn info_document( configured_model: &str, model_variant: Option<&str>, execution_mode: &ExecutionMode, - standard_context_approach: Option<&StandardContextApproach>, + rlm_dialect: Option, context_window: Option, tool_summary: Option<(usize, &str)>, cwd: &str, @@ -138,12 +140,10 @@ pub(crate) fn info_document( value: variant.to_string(), }); } - if *execution_mode == ExecutionMode::Standard - && let Some(standard_context_approach) = standard_context_approach - { + if let Some(dialect) = rlm_dialect { model_rows.push(DocumentRow::KeyValue { - label: "context approach".to_string(), - value: standard_context_approach_label(standard_context_approach).to_string(), + label: "rlm dialect".to_string(), + value: dialect.language_id().to_string(), }); } model_rows.push(DocumentRow::KeyValue { @@ -301,6 +301,11 @@ pub(crate) fn help_document(skills: &SkillCatalog, ui_extensions: &TuiExtensions #[cfg(test)] mod tests { use super::*; + + #[test] + fn trace_schema_is_pinned_to_eight() { + assert_eq!(lash_trace::TRACE_SCHEMA_VERSION, 8); + } use crate::keybindings::ShortcutHelpRow; #[test] @@ -370,7 +375,7 @@ mod tests { "google/gemini-3-flash-preview", Some("medium"), &ExecutionMode::Standard, - Some(&StandardContextApproach::RollingHistory(Default::default())), + None, Some(123_000), Some((7, "abcd1234")), cwd, @@ -442,7 +447,7 @@ mod tests { "google/gemini-3-flash-preview", Some("medium"), &ExecutionMode::Standard, - Some(&StandardContextApproach::RollingHistory(Default::default())), + None, Some(123_000), Some((7, "abcd1234")), cwd, diff --git a/crates/lash-cli/src/input_items.rs b/crates/lash-cli/src/input_items.rs index 5d36a0c..161fc94 100644 --- a/crates/lash-cli/src/input_items.rs +++ b/crates/lash-cli/src/input_items.rs @@ -1,27 +1,23 @@ -use std::collections::HashMap; use std::ops::Range; use std::path::PathBuf; use crate::app::App; use crate::editor::PendingImage; use lash::InputItem; +use lash::attachments::MediaType; +use lash::direct::AttachmentSource; /// Build structured turn items from editor input: /// - `@path` becomes a host-prepared text marker when resolvable /// - `[Image #n]` binds to pasted image `n` from this turn's image list /// - plain text remains `Text` -pub fn build_items_from_editor_input( - input: &str, - images: Vec, -) -> (Vec, HashMap>) { +pub fn build_items_from_editor_input(input: &str, images: Vec) -> Vec { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let mut items: Vec = Vec::new(); - let mut image_blobs: HashMap> = HashMap::new(); - let image_slots: HashMap> = images + let image_slots: std::collections::HashMap> = images .into_iter() .map(|image| (image.id, image.png_bytes)) .collect(); - let mut emitted_image_ids: HashMap = HashMap::new(); let mut text_buf = String::with_capacity(input.len()); let mut i = 0; @@ -32,14 +28,12 @@ pub fn build_items_from_editor_input( && let Some(bytes) = image_slots.get(&img_idx) { push_text_item(&mut items, &mut text_buf); - let id = emitted_image_ids - .entry(img_idx) - .or_insert_with(|| format!("img-{img_idx}")) - .clone(); - image_blobs - .entry(id.clone()) - .or_insert_with(|| bytes.clone()); - items.push(InputItem::image_ref(id)); + let media_type = MediaType::parse("image/png") + .expect("the built-in PNG media type must be syntactically valid"); + items.push(InputItem::attachment(AttachmentSource::inline( + media_type, + bytes.clone(), + ))); i = next_i; continue; } @@ -83,7 +77,7 @@ pub fn build_items_from_editor_input( push_text_item(&mut items, &mut text_buf); - (items, image_blobs) + items } fn push_text_item(items: &mut Vec, text: &mut String) { diff --git a/crates/lash-cli/src/interactive/commands/mod.rs b/crates/lash-cli/src/interactive/commands/mod.rs index 621be00..6854caa 100644 --- a/crates/lash-cli/src/interactive/commands/mod.rs +++ b/crates/lash-cli/src/interactive/commands/mod.rs @@ -104,7 +104,7 @@ async fn handle_ui_command( fn turn_input_has_visible_content(input: &lash_core::TurnInput) -> bool { input.items.iter().any(|item| match item { lash_core::InputItem::Text { text } => !text.trim().is_empty(), - lash_core::InputItem::ImageRef { id } => input.image_blobs.contains_key(id), + lash_core::InputItem::Attachment { .. } => true, }) } @@ -130,7 +130,7 @@ pub(super) async fn dispatch_queued_turn( _args: &Args, _paused: &Arc, _ui_extensions: &TuiExtensions, - _runtime_factory: &crate::startup::session::CliSessionOpener, + runtime_factory: &crate::startup::session::CliSessionOpener, _lash_config: &crate::config::LashConfig, runtime: &mut Option, _history: &mut Vec, @@ -178,7 +178,6 @@ pub(super) async fn dispatch_queued_turn( }); } send_queued_work( - Vec::new(), display_turns, app, ui_trace.as_mut(), @@ -188,6 +187,7 @@ pub(super) async fn dispatch_queued_turn( cancel_token, active_stream_id, app_tx, + runtime_factory, ) .await; Ok(true) @@ -238,6 +238,7 @@ pub(super) async fn handle_builtin_command( match cmd { command::Command::Exit => Ok(true), command::Command::Clear => { + let rlm_dialect = session::current_rlm_dialect(runtime); session::handle_clear( app, runtime_factory, @@ -250,6 +251,7 @@ pub(super) async fn handle_builtin_command( provider, current_model_variant, current_execution_mode, + rlm_dialect, active_tool_state, pending_clear_after_return, ) @@ -260,30 +262,31 @@ pub(super) async fn handle_builtin_command( Ok(false) } command::Command::Info => { + let mut active_tool_count = active_tool_state.len(); if let Some(session) = runtime.as_ref() - && let Ok(state) = session.admin().tools().state().await + && let Ok(manifests) = session.admin().tools().active_manifests().await { - *active_tool_state = state; - *toolset_hash = hash12( - &serde_json::to_vec(&active_tool_state.tool_manifests()) - .unwrap_or_else(|_| b"[]".to_vec()), - ); + active_tool_count = manifests.len(); + *toolset_hash = + hash12(&serde_json::to_vec(&manifests).unwrap_or_else(|_| b"[]".to_vec())); } let model = app.model.clone(); let context_window = app.usage.context_window; let cwd = app.cwd.clone(); let session_name = app.session_name.clone(); - let standard_context_approach = (current_execution_mode == &ExecutionMode::Standard) - .then(lash_standard_plugins::StandardContextApproach::default); + let rlm_dialect = runtime.as_ref().and_then(|session| { + use lash::rlm::RlmSessionExt as _; + session.rlm_config().dialect + }); let session_db_path = logger.db_path().to_string_lossy().to_string(); app.show_document(info_document( provider, &model, current_model_variant.as_deref(), current_execution_mode, - standard_context_approach.as_ref(), + rlm_dialect, context_window, - Some((active_tool_state.len(), toolset_hash)), + Some((active_tool_count, toolset_hash)), &cwd, Some(&session_name), Some(&logger.session_id), @@ -335,6 +338,7 @@ pub(super) async fn handle_builtin_command( current_execution_mode, toolset_hash, app_tx, + runtime_factory, ) .await } @@ -343,15 +347,7 @@ pub(super) async fn handle_builtin_command( Ok(false) } command::Command::Fork => { - session::handle_fork( - app, - logger, - runtime, - provider, - current_model_variant, - toolset_hash, - ) - .await + session::handle_fork(app, runtime_factory, logger, runtime, toolset_hash).await } command::Command::Tree => session::handle_tree(app, runtime).await, command::Command::Help => { @@ -420,3 +416,42 @@ pub(super) async fn handle_builtin_command( } } } + +pub(super) async fn handle_new_session( + dialect: crate::execution_settings::RlmDialect, + ctx: SlashCommandCtx<'_>, +) -> anyhow::Result { + let SlashCommandCtx { + app, + logger, + runtime_factory, + runtime, + history, + turn_counter, + last_turn, + active_stream_id, + provider, + current_model_variant, + current_execution_mode, + active_tool_state, + pending_clear_after_return, + .. + } = ctx; + session::handle_clear( + app, + runtime_factory, + logger, + runtime, + history, + turn_counter, + last_turn, + active_stream_id, + provider, + current_model_variant, + current_execution_mode, + Some(dialect), + active_tool_state, + pending_clear_after_return, + ) + .await +} diff --git a/crates/lash-cli/src/interactive/commands/provider.rs b/crates/lash-cli/src/interactive/commands/provider.rs index 20266bc..0ed182a 100644 --- a/crates/lash-cli/src/interactive/commands/provider.rs +++ b/crates/lash-cli/src/interactive/commands/provider.rs @@ -27,7 +27,13 @@ fn save_model_default( ) { let mut cfg = match LashConfig::load(&crate::paths::config_file()) { Some(cfg) => cfg, - None => LashConfig::new(provider), + None => { + push_system_message( + app, + "Model updated, but no provider config is available to save the default.", + ); + return; + } }; cfg.set_model_default(provider.kind(), model.to_string(), variant); if let Err(err) = cfg.save(&crate::paths::config_file()) { @@ -40,10 +46,9 @@ fn save_model_default( fn persist_provider_config( cfg: &mut LashConfig, - provider: &ProviderHandle, + _provider: &ProviderHandle, path: &std::path::Path, ) -> std::io::Result<()> { - cfg.upsert_provider(provider); cfg.save(path) } @@ -205,7 +210,14 @@ pub(super) fn handle_mode( app, format!( "Current execution mode: `{}`\nThis is locked for the current session.\nStart a new session to use a different mode.\nUsage: `/mode {}`", - execution_mode_label(current_execution_mode), + if let Some(dialect) = app.rlm_dialect_label.as_deref() { + format!( + "{} · {dialect}", + execution_mode_label(current_execution_mode) + ) + } else { + execution_mode_label(current_execution_mode).to_string() + }, execution_mode_usage() ), ); @@ -512,19 +524,20 @@ mod tests { let temp = TempDirGuard::new("lash-provider-persist"); let _lash_home = EnvVarGuard::set("LASH_HOME", temp.path()); let path = crate::paths::config_file(); - let provider = lash_core::testing::TestProvider::builder() + let concrete = lash_core::testing::TestProvider::builder() .kind("persist-provider") .serialize_config(|| serde_json::json!({ "token": "refreshed" })) - .build() - .into_handle(); - let mut cfg = LashConfig::new(&provider); + .build(); + let provider_config = crate::config::ProviderConfig::from_provider(&concrete); + let provider = concrete.into_handle(); + let mut cfg = LashConfig::new(provider_config); persist_provider_config(&mut cfg, &provider, &path).expect("persist provider config"); let saved = LashConfig::load(&path).expect("saved config"); assert_eq!(saved.active_provider_kind(), "persist-provider"); let spec = saved - .provider_spec("persist-provider") + .provider_config("persist-provider") .expect("provider spec"); assert_eq!( spec.config.get("token").and_then(serde_json::Value::as_str), diff --git a/crates/lash-cli/src/interactive/commands/session.rs b/crates/lash-cli/src/interactive/commands/session.rs index 2d295cd..103f8fe 100644 --- a/crates/lash-cli/src/interactive/commands/session.rs +++ b/crates/lash-cli/src/interactive/commands/session.rs @@ -2,7 +2,6 @@ use lash::CancellationToken; use lash::{LashSession, provider::ProviderHandle}; use lash_core::session_model::Message; use lash_core::{SessionPolicy, ToolState}; -use lash_standard_plugins::StandardContextApproach; use crate::SkillCatalog; use crate::app::{App, UiTimelineItem}; @@ -52,6 +51,7 @@ async fn activate_opened_session( let Some(session) = runtime.as_ref() else { return Err("opened session was not installed".to_string()); }; + app.set_rlm_dialect(current_rlm_dialect(runtime)); session .admin() .commands() @@ -74,28 +74,31 @@ fn fallback_policy_for_session_switch( current_model_variant: &mut Option, _current_execution_mode: &mut ExecutionMode, ) -> SessionPolicy { - let model = lash_core::ModelSpec::from_token_limits( - app.model.clone(), - crate::model_selection::reasoning_selection_from_variant(current_model_variant.clone()), - app.usage.context_window.unwrap_or(1) as usize, - None, - ) - .unwrap_or_else(|_| lash_core::ModelSpec::default()) - .with_capability(crate::capability_catalog::capability_for( - provider.kind(), - &app.model, - )); - SessionPolicy { - provider_id: provider.kind().to_string(), - model, - ..SessionPolicy::default() - } + let model = lash_core::ModelSpec::builder(app.model.clone()) + .variant(crate::model_selection::reasoning_selection_from_variant( + current_model_variant.clone(), + )) + .context_window_tokens(app.usage.context_window.unwrap_or(1) as usize) + .build() + .unwrap_or_else(|_| lash_core::ModelSpec::default()) + .with_capability(crate::capability_catalog::capability_for( + provider.kind(), + &app.model, + )); + let mut policy = SessionPolicy::new(crate::host_policy::turn_budget()); + policy.provider_id = provider.kind().to_string(); + policy.model = model; + policy.no_progress_budget = crate::host_policy::no_progress_budget(); + policy } -fn fallback_standard_context_approach( - current_execution_mode: &ExecutionMode, -) -> Option { - (current_execution_mode == &ExecutionMode::Standard).then(StandardContextApproach::default) +pub(super) fn current_rlm_dialect( + runtime: &Option, +) -> Option { + use lash::rlm::RlmSessionExt as _; + runtime + .as_ref() + .and_then(|session| session.rlm_config().dialect) } #[allow(clippy::too_many_arguments)] @@ -111,6 +114,7 @@ pub(super) async fn handle_clear( provider: &ProviderHandle, current_model_variant: &mut Option, current_execution_mode: &mut ExecutionMode, + new_rlm_dialect: Option, active_tool_state: &mut ToolState, pending_clear_after_return: &mut bool, ) -> anyhow::Result { @@ -127,17 +131,14 @@ pub(super) async fn handle_clear( current_execution_mode, ); let opened = runtime_factory - .fresh( - policy, - *current_execution_mode, - fallback_standard_context_approach(current_execution_mode), - ) + .fresh(policy, *current_execution_mode, new_rlm_dialect) .await?; *runtime = Some(opened.session); *logger = opened.logger; let Some(session) = runtime.as_ref() else { return Err(anyhow::anyhow!("opened session was not installed")); }; + app.set_rlm_dialect(current_rlm_dialect(runtime)); if let Some(rt) = runtime.as_ref() { rt.admin() .commands() @@ -179,6 +180,7 @@ pub(super) async fn handle_retry( current_execution_mode: &mut ExecutionMode, toolset_hash: &mut String, app_tx: &crate::event::AppEventTx, + runtime_factory: &CliSessionOpener, ) -> anyhow::Result { if let Some(previous) = last_turn.clone() { let definitions = match runtime.as_ref() { @@ -206,6 +208,7 @@ pub(super) async fn handle_retry( cancel_token, active_stream_id, app_tx, + runtime_factory, ) .await; } else { @@ -216,24 +219,12 @@ pub(super) async fn handle_retry( pub(super) async fn handle_fork( app: &mut App, + runtime_factory: &CliSessionOpener, logger: &mut SessionLogger, runtime: &mut Option, - provider: &ProviderHandle, - current_model_variant: &Option, _toolset_hash: &str, ) -> anyhow::Result { - match fork::fork_current_session( - runtime.as_ref(), - logger, - provider, - &app.model, - app.usage - .context_window - .expect("app context_window must be set before forking"), - current_model_variant.as_deref(), - ) - .await - { + match fork::fork_current_session(runtime.as_ref(), runtime_factory, logger, &app.model).await { Ok(forked) => { let fallback_command = fork_resume_command(&forked.session_id); let exe = match fork::resolve_resume_executable() { @@ -341,7 +332,7 @@ pub(crate) async fn switch_to_session_identifier( identifier, policy, *current_execution_mode, - fallback_standard_context_approach(current_execution_mode), + current_rlm_dialect(runtime), ) .await?; activate_opened_session( @@ -359,9 +350,14 @@ pub(crate) async fn switch_to_session_identifier( ) .await .map_err(anyhow::Error::msg)?; - *toolset_hash = hash12( - &serde_json::to_vec(&active_tool_state.tool_manifests()).unwrap_or_else(|_| b"[]".to_vec()), - ); + let manifests = runtime + .as_ref() + .ok_or_else(|| anyhow::anyhow!("opened session was not installed"))? + .admin() + .tools() + .active_manifests() + .await?; + *toolset_hash = hash12(&serde_json::to_vec(&manifests).unwrap_or_else(|_| b"[]".to_vec())); Ok(()) } @@ -413,10 +409,11 @@ pub(super) async fn handle_resume( } } else { const SESSION_PICKER_LIMIT: usize = 50; + let incompatible_count = session_log::incompatible_session_count(); let current_session_id = logger.session_id.clone(); let mut sessions = session_log::list_recent_sessions(SESSION_PICKER_LIMIT + 1).await; sessions.retain(|si| si.session_id != current_session_id); - if sessions.is_empty() { + if sessions.is_empty() && incompatible_count == 0 { app.timeline.push(UiTimelineItem::SystemMessage( "No sessions found.".to_string(), )); @@ -424,7 +421,7 @@ pub(super) async fn handle_resume( app.scroll_to_bottom(); } else { sessions.truncate(SESSION_PICKER_LIMIT); - app.show_session_picker(sessions); + app.show_session_picker_with_incompatible(sessions, incompatible_count); } } Ok(false) diff --git a/crates/lash-cli/src/interactive/helpers.rs b/crates/lash-cli/src/interactive/helpers.rs index 92ab991..38ca228 100644 --- a/crates/lash-cli/src/interactive/helpers.rs +++ b/crates/lash-cli/src/interactive/helpers.rs @@ -1,8 +1,9 @@ use crossterm::event::{KeyCode, KeyModifiers}; use futures_util::StreamExt as _; +use lash::observe::LiveReplayGap; use lash::observe::{SessionCursor, SessionObservationEventPayload, SessionObservationStreamItem}; use lash::{LashSession, TurnActivity, TurnActivityId, TurnEvent, TurnInput}; -use lash_core::{LiveReplayGap, SessionPolicy, SessionSnapshot}; +use lash_core::{SessionPolicy, SessionSnapshot}; use lash_tui_extensions::{ KeyChord as UiKeyChord, KeyCode as UiKeyCode, KeyModifiers as UiKeyModifiers, }; @@ -50,7 +51,7 @@ impl SessionObservationBridge { } next = subscription.next() => { match next { - Some(Ok(SessionObservationStreamItem::Event(event))) => match event.payload { + Some(Ok(SessionObservationStreamItem::Event(event))) => match event.payload.clone() { SessionObservationEventPayload::TurnActivity(activity) => { coalescer.emit(activity).await; } @@ -80,6 +81,10 @@ impl SessionObservationBridge { let _ = app_tx.send(AppEvent::SessionObservationFinished { stream_id }); break; } + SessionObservationEventPayload::ResidentChanged { .. } => { + coalescer.flush().await; + let _ = app_tx.send(AppEvent::RequestUiSnapshot); + } }, Some(Ok(SessionObservationStreamItem::Gap { gap, .. })) => { coalescer.flush().await; @@ -167,12 +172,12 @@ impl TurnActivityCoalescer { let correlation_id = self .pending_correlation_id .take() - .unwrap_or_else(TurnActivityId::fresh); + .unwrap_or_else(|| TurnActivityId::new(uuid::Uuid::new_v4().to_string())); let _ = self.app_tx.send(AppEvent::Session { stream_id: self.stream_id, activity: Box::new(TurnActivity::new( correlation_id, - TurnEvent::AssistantProseDelta { text }, + TurnEvent::AssistantProseDelta { text: text.into() }, )), }); if self.coalesced > 0 { @@ -289,10 +294,7 @@ impl UiSnapshotWorker { } pub(super) fn cleared_session_state(policy: SessionPolicy) -> SessionSnapshot { - SessionSnapshot { - policy, - ..SessionSnapshot::default() - } + SessionSnapshot::new(policy) } pub(super) fn log_runtime_transition( diff --git a/crates/lash-cli/src/interactive/input_handling/mod.rs b/crates/lash-cli/src/interactive/input_handling/mod.rs index 7f5983a..33163b9 100644 --- a/crates/lash-cli/src/interactive/input_handling/mod.rs +++ b/crates/lash-cli/src/interactive/input_handling/mod.rs @@ -36,12 +36,6 @@ use super::commands::SlashCommandCtx; use super::helpers::{TurnReplayPayload, key_chord_from_event}; pub(super) use mouse::handle_mouse_event; -#[cfg(test)] -pub(crate) use shortcuts::command_palette_items; -#[cfg(test)] -pub(super) use turns::selected_slash_command_suggestion; -#[cfg(test)] -pub(super) use turns::slash_command_blocked_while_working_message; /// Bundle of the long-lived interactive-loop state every key handler /// needs. The run loop borrows its locals into this once per key event diff --git a/crates/lash-cli/src/interactive/input_handling/modals.rs b/crates/lash-cli/src/interactive/input_handling/modals.rs index d773654..d68fcf9 100644 --- a/crates/lash-cli/src/interactive/input_handling/modals.rs +++ b/crates/lash-cli/src/interactive/input_handling/modals.rs @@ -62,6 +62,10 @@ async fn execute_command_palette_action( let slash_ctx = ctx.slash_ctx(); super::super::commands::handle_builtin_command(command, slash_ctx).await } + CommandPaletteAction::NewSession(dialect) => { + let slash_ctx = ctx.slash_ctx(); + super::super::commands::handle_new_session(dialect, slash_ctx).await + } CommandPaletteAction::InsertDraft(draft) => { ctx.app.set_input(draft); ctx.app.update_suggestions(); @@ -74,11 +78,20 @@ async fn execute_command_palette_action( } } -fn apply_theme_choice(choice: ThemeName, app: &mut App, provider: &lash::provider::ProviderHandle) { +fn apply_theme_choice( + choice: ThemeName, + app: &mut App, + _provider: &lash::provider::ProviderHandle, +) { theme::set_active_theme(choice); - let mut config = - LashConfig::load(&crate::paths::config_file()).unwrap_or_else(|| LashConfig::new(provider)); - config.upsert_provider(provider); + let Some(mut config) = LashConfig::load(&crate::paths::config_file()) else { + push_system_message( + app, + "Theme changed, but no provider config is available to save it.", + ); + app.dirty = true; + return; + }; config.theme = choice; match config.save(&crate::paths::config_file()) { Ok(()) => app.show_toast( diff --git a/crates/lash-cli/src/interactive/input_handling/shortcuts.rs b/crates/lash-cli/src/interactive/input_handling/shortcuts.rs index df41c35..57a3d47 100644 --- a/crates/lash-cli/src/interactive/input_handling/shortcuts.rs +++ b/crates/lash-cli/src/interactive/input_handling/shortcuts.rs @@ -88,6 +88,10 @@ pub(super) async fn handle_global_shortcut_key( } if app.turn_active() { app.note_manual_interrupt_requested(); + app.timeline + .push_system_message_if_new(crate::util::manual_interrupt_message().to_string()); + retire_pending_active_turn_inputs(ctx.runtime.as_ref(), *ctx.active_stream_id).await; + request_exact_turn_cancel(ctx.runtime.as_ref(), *ctx.active_stream_id).await; if let Some(token) = cancel_token.take() { token.cancel(); } @@ -194,6 +198,12 @@ pub(super) async fn handle_global_shortcut_key( } else if app.turn_active() { // Interrupt running session app.note_manual_interrupt_requested(); + app.timeline.push_system_message_if_new( + crate::util::manual_interrupt_message().to_string(), + ); + retire_pending_active_turn_inputs(ctx.runtime.as_ref(), *ctx.active_stream_id) + .await; + request_exact_turn_cancel(ctx.runtime.as_ref(), *ctx.active_stream_id).await; if let Some(token) = cancel_token.take() { token.cancel(); } @@ -206,6 +216,58 @@ pub(super) async fn handle_global_shortcut_key( Ok(None) } +async fn retire_pending_active_turn_inputs(session: Option<&lash::LashSession>, stream_id: u64) { + let Some(session) = session else { + return; + }; + let turn_id = format!("cli-turn:{stream_id}"); + let pending = match session.pending_turn_inputs().await { + Ok(pending) => pending, + Err(error) => { + tracing::warn!(%turn_id, %error, "failed to inspect active-turn inputs before cancellation"); + Vec::new() + } + }; + let candidates = pending + .into_iter() + .filter(|input| { + input.ingress.active_turn_id() == Some(turn_id.as_str()) + && !matches!( + input.state, + lash_core::TurnInputState::Cancelled | lash_core::TurnInputState::Completed + ) + }) + .collect::>(); + let targets = candidates + .iter() + .map(|input| lash::PendingTurnInputCancelTarget::input_id(input.input_id.clone())) + .collect::>(); + if !targets.is_empty() + && let Err(error) = session.cancel_pending_turn_inputs(targets).await + { + tracing::warn!(%turn_id, %error, "failed to retire active-turn inputs before cancellation"); + } +} + +async fn request_exact_turn_cancel(session: Option<&lash::LashSession>, stream_id: u64) { + let Some(session) = session else { + return; + }; + let turn_id = format!("cli-turn:{stream_id}"); + let request_id = format!("cli-cancel:{}", uuid::Uuid::new_v4()); + if let Err(error) = session + .request_turn_cancel( + &turn_id, + request_id, + Some("interactive-user".to_string()), + Some("operator requested turn cancellation".to_string()), + ) + .await + { + tracing::warn!(%turn_id, %error, "exact turn cancellation request failed"); + } +} + fn is_command_palette_shortcut(key: KeyEvent) -> bool { if key.modifiers.contains(KeyModifiers::SHIFT) { return false; @@ -324,14 +386,35 @@ pub(crate) fn command_palette_items( .footer("/logout"), ); + if app.execution_mode_label == "rlm" { + for dialect in crate::execution_settings::RlmDialect::ALL { + items.push( + CommandPaletteItem::new( + "Session", + format!("New Session: {}", dialect.language_id()), + format!( + "Start a fresh RLM session pinned to {}.", + dialect.language_id() + ), + CommandPaletteAction::NewSession(dialect), + ) + .footer(format!("rlm · {}", dialect.language_id())) + .current(app.rlm_dialect_label.as_deref() == Some(dialect.language_id())), + ); + } + } else { + items.push( + CommandPaletteItem::new( + "Session", + "New Session", + "Reset conversation and start fresh.", + CommandPaletteAction::Builtin(command::Command::Clear), + ) + .footer("/clear"), + ); + } + items.extend([ - CommandPaletteItem::new( - "Session", - "New Session", - "Reset conversation and start fresh.", - CommandPaletteAction::Builtin(command::Command::Clear), - ) - .footer("/clear"), CommandPaletteItem::new( "Session", "Resume Session", diff --git a/crates/lash-cli/src/interactive/input_handling/turns.rs b/crates/lash-cli/src/interactive/input_handling/turns.rs index 98ece75..8871cab 100644 --- a/crates/lash-cli/src/interactive/input_handling/turns.rs +++ b/crates/lash-cli/src/interactive/input_handling/turns.rs @@ -388,6 +388,7 @@ async fn handle_tab_submit(ctx: &mut SessionCtx<'_>) -> anyhow::Result { ctx.cancel_token, ctx.active_stream_id, ctx.app_tx, + ctx.runtime_factory, ) .await; *ctx.last_turn = Some(TurnReplayPayload { @@ -519,7 +520,7 @@ async fn handle_enter_submit(ctx: &mut SessionCtx<'_>) -> anyhow::Result { { Ok(()) => { record_queue_current_turn_input(ctx.ui_trace, &queued); - ctx.app.cache_draft_presentation(queued.clone()); + ctx.app.cache_active_turn_draft(queued.clone()); } Err(err) => { push_system_message( @@ -573,6 +574,7 @@ async fn handle_enter_submit(ctx: &mut SessionCtx<'_>) -> anyhow::Result { ctx.cancel_token, ctx.active_stream_id, ctx.app_tx, + ctx.runtime_factory, ) .await; *ctx.last_turn = Some(TurnReplayPayload { diff --git a/crates/lash-cli/src/interactive/mod.rs b/crates/lash-cli/src/interactive/mod.rs index 9b306a0..308c368 100644 --- a/crates/lash-cli/src/interactive/mod.rs +++ b/crates/lash-cli/src/interactive/mod.rs @@ -2,8 +2,6 @@ mod commands; mod helpers; mod input_handling; mod runtime; -#[cfg(test)] -mod tests; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -14,7 +12,6 @@ use lash::{LashSession, TurnEvent, provider::ProviderHandle}; use lash_core::runtime::RuntimeSessionState; use lash_core::session_model::Message; use lash_core::{TokenUsage, ToolState}; -use lash_sqlite_store::Store; use lash_tui::{InputEvent as TuiInputEvent, Terminal, normalize_event}; use lash_tui_extensions::{TuiExtensionContext, TuiExtensions, TuiSlashInvocation}; @@ -89,15 +86,16 @@ pub(crate) async fn run_app( initial_context_window: u64, session_name: String, model_catalog: Arc, - _store: Arc, mut toolset_hash: String, initial_model_variant: Option, initial_execution_mode: ExecutionMode, + initial_rlm_dialect: Option, startup_system_message: Option, ) -> anyhow::Result<()> { let initial_session_id = session.session_id(); let mut app = App::new(model, session_name, initial_session_id); app.set_execution_mode_label(&initial_execution_mode); + app.set_rlm_dialect(initial_rlm_dialect); let (chrome_ext, chrome_state) = crate::chrome_ui::ChromeTuiExtension::new(); let extra_ui_extensions: Vec> = vec![ Arc::new(lash_autoresearch::AutoresearchTuiExtension::default()), @@ -267,10 +265,12 @@ pub(crate) async fn run_app( push_system_message(&mut app, err); } else { let _ = app_tx.send(AppEvent::RequestUiSnapshot); - toolset_hash = hash12( - &serde_json::to_vec(&active_tool_state.tool_manifests()) - .unwrap_or_else(|_| b"[]".to_vec()), - ); + if let Some(session) = runtime.as_ref() + && let Ok(manifests) = session.admin().tools().active_manifests().await + { + toolset_hash = + hash12(&serde_json::to_vec(&manifests).unwrap_or_else(|_| b"[]".to_vec())); + } } if let Some(prompt) = args .resume_prompt @@ -292,6 +292,7 @@ pub(crate) async fn run_app( &mut cancel_token, &mut active_stream_id, &app_tx, + &runtime_factory, ) .await; last_turn = Some(TurnReplayPayload { @@ -400,7 +401,7 @@ pub(crate) async fn run_app( } let interrupted = matches!( &done.result.outcome, - lash_core::TurnOutcome::Stopped(lash_core::TurnStop::Cancelled) + lash::TurnOutcome::Stopped(lash::TurnStop::Cancelled { .. }) ); let active_turn_had_visible_output = app .live @@ -411,6 +412,13 @@ pub(crate) async fn run_app( &done.result, active_turn_had_visible_output, ); + let completed_turn_usage = done.result.total_usage(); + let session_usage = runtime + .as_ref() + .expect("runtime remains installed while a turn runs") + .usage_report() + .usage + .usage; let state = done.result.state; tracing::info!( turn_index = state.turn_index, @@ -444,7 +452,8 @@ pub(crate) async fn run_app( let read_view = state.read_view(); history = read_view.messages().to_vec(); turn_counter = state.turn_index; - app.usage.token_usage = state.token_usage.clone(); + app.usage.last_response_usage = completed_turn_usage; + app.usage.token_usage = session_usage; app.usage.last_prompt_usage = state.last_prompt_usage.clone(); tracing::debug!( stream_id = done.stream_id, @@ -457,6 +466,7 @@ pub(crate) async fn run_app( "reconciling completed runtime turn" ); if interrupted { + let interrupted_active_turn_drafts = app.take_active_turn_drafts(); let had_manual_interrupt_message = matches!( app.timeline.last(), Some(UiTimelineItem::SystemMessage(message)) @@ -473,8 +483,16 @@ pub(crate) async fn run_app( &ui_projection_state, interrupted_message.clone(), ); + app.clear_manual_interrupt_requested(); runtime_return_rx = None; cancel_token = None; + defer_interrupted_active_turn_drafts( + runtime.as_ref(), + &mut app, + done.stream_id, + interrupted_active_turn_drafts, + ) + .await; dispatch_queued_turn( &mut app, &mut ui_trace, @@ -503,6 +521,7 @@ pub(crate) async fn run_app( } app.finish_turn_from_read_view(&read_view); + app.clear_manual_interrupt_requested(); runtime_return_rx = None; cancel_token = None; log_runtime_transition( @@ -959,3 +978,36 @@ pub(crate) async fn run_app( Ok(()) } + +async fn defer_interrupted_active_turn_drafts( + session: Option<&LashSession>, + app: &mut App, + stream_id: u64, + drafts: Vec, +) { + let Some(session) = session else { + for draft in drafts { + app.restore_prepared_turn(draft); + } + return; + }; + for draft in drafts { + let replay_id = format!("interrupt-replay:{stream_id}:{}", draft.draft_id); + match session + .enqueue(make_turn_input(&draft)) + .id(replay_id) + .ingress(lash_core::TurnInputIngress::NextTurn) + .send() + .await + { + Ok(_) => app.cache_draft_presentation(draft), + Err(error) => { + push_system_message( + app, + format!("Failed to preserve interrupted input: {error}"), + ); + app.restore_prepared_turn(draft); + } + } + } +} diff --git a/crates/lash-cli/src/interactive/runtime.rs b/crates/lash-cli/src/interactive/runtime.rs index 2f21041..e0eceba 100644 --- a/crates/lash-cli/src/interactive/runtime.rs +++ b/crates/lash-cli/src/interactive/runtime.rs @@ -17,69 +17,30 @@ use crate::input_items::build_items_from_editor_input; #[cfg(test)] use lash::InputItem; #[cfg(test)] -use lash_core::PluginMessage; +use lash_core::MessageRole; #[cfg(test)] -use lash_core::session_model::{Part, PartKind, PruneState}; +use lash_core::PluginMessage; #[cfg(test)] -use lash_core::{AttachmentCreateMeta, AttachmentStore, ImageMediaType, MediaType, MessageRole}; +use lash_core::session_model::{Part, PartKind}; #[cfg(test)] pub(crate) async fn make_injected_plugin_message(turn: &PreparedTurn) -> PluginMessage { - let (items, image_blobs) = - build_items_from_editor_input(&turn.effective_text, turn.images.clone()); - let attachment_store = lash_core::InMemoryAttachmentStore::new(); + let items = build_items_from_editor_input(&turn.effective_text, turn.images.clone()); let mut parts = Vec::new(); - let mut image_ids = Vec::new(); for item in items { match item { InputItem::Text { text } => { if text.is_empty() { continue; } - parts.push(Part { - id: String::new(), - kind: PartKind::Text, - content: text, - attachment: None, - tool_call_id: None, - tool_name: None, - tool_replay: None, - prune_state: PruneState::Intact, - reasoning_meta: None, - response_meta: None, - }); + parts.push(Part::text(String::new(), text, None)); } - InputItem::ImageRef { id } => { - let Some(bytes) = image_blobs.get(&id) else { - continue; - }; - if image_ids.iter().any(|candidate| candidate == &id) { - continue; - } - image_ids.push(id.clone()); - let meta = AttachmentCreateMeta::new( - MediaType::Image(ImageMediaType::Png), - None, - None, - Some(id.clone()), - ); - let Ok(reference) = attachment_store.put(bytes.clone(), meta).await else { - continue; - }; - parts.push(Part { - id: String::new(), - kind: PartKind::Image, - content: String::new(), - attachment: Some(lash_core::session_model::message::PartAttachment { - reference, - }), - tool_call_id: None, - tool_name: None, - tool_replay: None, - prune_state: PruneState::Intact, - reasoning_meta: None, - response_meta: None, - }); + InputItem::Attachment { source } => { + parts.push(Part::attachment_part( + String::new(), + String::new(), + Some(lash_core::session_model::message::PartAttachment { source }), + )); } } } @@ -90,7 +51,7 @@ pub(crate) async fn make_injected_plugin_message(turn: &PreparedTurn) -> PluginM content: turn.effective_text.clone(), origin: None, parts, - images: Vec::new(), + attachments: Vec::new(), } } @@ -100,7 +61,7 @@ pub(crate) fn injected_image_part_indices(message: &PluginMessage) -> Vec .parts .iter() .enumerate() - .filter_map(|(idx, part)| matches!(part.kind, PartKind::Image).then_some(idx)) + .filter_map(|(idx, part)| matches!(part.kind, PartKind::Attachment).then_some(idx)) .collect() } @@ -162,6 +123,7 @@ pub(super) async fn send_user_message( cancel_token: &mut Option, active_stream_id: &mut u64, app_tx: &AppEventTx, + runtime_factory: &crate::startup::session::CliSessionOpener, ) { let mut ui_trace = ui_trace; if !prepared_turn.display_text.is_empty() { @@ -195,7 +157,11 @@ pub(super) async fn send_user_message( .expect("runtime should be available when not running"); tracing::info!( items = turn_input.items.len(), - images = turn_input.image_blobs.len(), + attachments = turn_input + .items + .iter() + .filter(|item| matches!(item, lash::InputItem::Attachment { .. })) + .count(), "dispatching runtime turn" ); *active_stream_id = active_stream_id.wrapping_add(1); @@ -210,14 +176,18 @@ pub(super) async fn send_user_message( ); SessionObservationBridge::spawn(&session, stream_id, app_tx.clone()); - let (cancel, return_rx) = spawn_session_turn(session, turn_input, stream_id); + let (cancel, return_rx) = spawn_session_turn( + session, + turn_input, + stream_id, + runtime_factory.active_effect_host(), + ); *cancel_token = Some(cancel); *runtime_return_rx = Some(return_rx); } #[allow(clippy::too_many_arguments)] pub(super) async fn send_queued_work( - batch_ids: Vec, prepared_turns: Vec, app: &mut App, ui_trace: Option<&mut UiTraceRecorder>, @@ -227,6 +197,7 @@ pub(super) async fn send_queued_work( cancel_token: &mut Option, active_stream_id: &mut u64, app_tx: &AppEventTx, + runtime_factory: &crate::startup::session::CliSessionOpener, ) { let mut ui_trace = ui_trace; for prepared_turn in &prepared_turns { @@ -265,7 +236,8 @@ pub(super) async fn send_queued_work( ); SessionObservationBridge::spawn(&session, stream_id, app_tx.clone()); - let (cancel, return_rx) = spawn_session_queued_turn(session, batch_ids, stream_id); + let (cancel, return_rx) = + spawn_session_queued_turn(session, stream_id, runtime_factory.active_effect_host()); *cancel_token = Some(cancel); *runtime_return_rx = Some(return_rx); } @@ -361,9 +333,11 @@ pub(crate) async fn generate_session_name(sessions_dir: &std::path::Path) -> Str if let Ok(entries) = std::fs::read_dir(sessions_dir) { for entry in entries.flatten() { let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) == Some("db") - && let Some(filename) = path.file_name().and_then(|name| name.to_str()) - && let Ok(start) = session_log::load_session_start(filename).await + if let Some(session_id) = path + .file_name() + .and_then(|name| name.to_str()) + .and_then(|name| name.strip_suffix(".ui.json")) + && let Ok(start) = session_log::load_session_start(session_id).await { existing.insert(start.session_name); } diff --git a/crates/lash-cli/src/interactive/tests.rs b/crates/lash-cli/src/interactive/tests.rs deleted file mode 100644 index eae5af3..0000000 --- a/crates/lash-cli/src/interactive/tests.rs +++ /dev/null @@ -1,607 +0,0 @@ -use super::*; -use std::sync::Arc; - -use crate::app::App; -use crate::clipboard::{ClipboardEnv, osc52_allowed_by_env, osc52_sequence_for}; -use crate::editor::LARGE_PASTE_CHAR_THRESHOLD; -use lash::{TurnActivity, TurnActivityId}; -use lash_core::SessionPolicy; - -#[tokio::test] -async fn observation_activity_coalescer_batches_text_before_structural_event() { - let mut pump = crate::event::AppEventPump::new(); - let mut coalescer = super::helpers::TurnActivityCoalescer::new(7, pump.sender()); - - coalescer - .emit(TurnActivity::new( - TurnActivityId::new("assistant"), - TurnEvent::AssistantProseDelta { - text: "Hello".to_string(), - }, - )) - .await; - coalescer - .emit(TurnActivity::new( - TurnActivityId::new("assistant"), - TurnEvent::AssistantProseDelta { - text: ", world".to_string(), - }, - )) - .await; - coalescer - .emit(TurnActivity::independent(TurnEvent::ModelRequestStarted { - protocol_iteration: 0, - })) - .await; - - let first = pump.recv().await.expect("text event").event; - let AppEvent::Session { - stream_id: 7, - activity, - } = first - else { - panic!("expected coalesced text delta"); - }; - let TurnActivity { - event: TurnEvent::AssistantProseDelta { text }, - .. - } = *activity - else { - panic!("expected coalesced text delta"); - }; - assert_eq!(text, "Hello, world"); - - let second = pump.recv().await.expect("structural event").event; - let AppEvent::Session { - stream_id: 7, - activity, - } = second - else { - panic!("expected structural event"); - }; - assert!(matches!( - activity.event, - TurnEvent::ModelRequestStarted { .. } - )); -} - -#[tokio::test] -async fn cli_turn_runner_streams_to_ui_through_session_observation() { - let session = observation_test_session( - "cli-observation-bridge", - Some("Hello from observation"), - None, - ) - .await; - let mut pump = crate::event::AppEventPump::new(); - let stream_id = 42; - - super::helpers::SessionObservationBridge::spawn(&session, stream_id, pump.sender()); - let (_cancel, return_rx) = crate::turn_runner::spawn_session_turn( - session, - lash_core::TurnInput::text("hello"), - stream_id, - ); - - let mut saw_text = false; - let mut saw_finished = false; - while !saw_text || !saw_finished { - let event = tokio::time::timeout(std::time::Duration::from_secs(2), pump.recv()) - .await - .expect("observation event") - .expect("app event") - .event; - match event { - AppEvent::Session { - stream_id: observed, - activity, - } => { - assert_eq!(observed, stream_id); - if let TurnEvent::AssistantProseDelta { text } = activity.event { - assert_eq!(text, "Hello from observation"); - saw_text = true; - } - } - AppEvent::SessionObservationFinished { - stream_id: observed, - } => { - assert_eq!(observed, stream_id); - saw_finished = true; - } - _ => {} - } - } - - let done = return_rx.await.expect("turn result"); - assert_eq!(done.stream_id, stream_id); - assert!(matches!( - done.result.outcome, - lash_core::TurnOutcome::Finished(_) - )); -} - -#[tokio::test] -async fn session_observation_bridge_surfaces_gap_and_requests_refresh() { - let session = - observation_test_session("cli-observation-gap", Some("gap response"), Some(1)).await; - let stale_cursor = session.observe().current_observation().cursor; - session - .turn(lash_core::TurnInput::text("first")) - .run() - .await - .expect("first turn"); - session - .turn(lash_core::TurnInput::text("second")) - .run() - .await - .expect("second turn"); - - let mut pump = crate::event::AppEventPump::new(); - super::helpers::SessionObservationBridge::spawn_from_cursor( - &session, - stale_cursor, - 77, - pump.sender(), - ); - - let mut saw_diagnostic = false; - let mut saw_pending_input_refresh = false; - let mut saw_ui_refresh = false; - for _ in 0..8 { - let event = tokio::time::timeout(std::time::Duration::from_secs(2), pump.recv()) - .await - .expect("gap bridge event") - .expect("app event") - .event; - match event { - AppEvent::SystemMessage { message } => { - saw_diagnostic |= - message.contains("Live session observation skipped buffered events"); - } - AppEvent::RequestPendingTurnInputSnapshot => saw_pending_input_refresh = true, - AppEvent::RequestUiSnapshot => saw_ui_refresh = true, - _ => {} - } - if saw_diagnostic && saw_pending_input_refresh && saw_ui_refresh { - break; - } - } - assert!(saw_diagnostic, "expected live replay gap diagnostic"); - assert!( - saw_pending_input_refresh, - "expected pending-input snapshot refresh request" - ); - assert!(saw_ui_refresh, "expected UI snapshot refresh request"); -} - -#[tokio::test] -async fn session_observation_bridge_surfaces_process_changes() { - let session = observation_test_session("cli-process-observation", Some("unused"), None).await; - let mut pump = crate::event::AppEventPump::new(); - let stream_id = 91; - let process_id = "bridge-process"; - - super::helpers::SessionObservationBridge::spawn(&session, stream_id, pump.sender()); - session - .processes() - .start( - lash_core::ProcessStartRequest::external( - process_id, - lash_core::ProcessOriginator::host(), - serde_json::Value::Null, - ) - .with_grant(Some(lash_core::ProcessStartGrant { - session_scope: lash_core::SessionScope::new("bridge-process-descriptor"), - descriptor: lash_core::ProcessHandleDescriptor::new( - Some("test"), - Some("bridge process"), - ), - })), - inline_scope(lash_core::ExecutionScope::process(process_id)), - ) - .await - .expect("start process"); - session - .processes() - .cancel( - process_id, - inline_scope(lash_core::ExecutionScope::process(process_id)), - ) - .await - .expect("cancel process"); - - let mut saw_started = false; - let mut saw_cancelled = false; - let mut ui_refreshes = 0; - for _ in 0..12 { - let event = tokio::time::timeout(std::time::Duration::from_secs(2), pump.recv()) - .await - .expect("process bridge event") - .expect("app event") - .event; - match event { - AppEvent::ProcessChanged { - stream_id: observed, - kind, - process_ids, - } => { - assert_eq!(observed, stream_id); - assert_eq!(process_ids, vec![process_id.to_string()]); - match kind { - lash_core::SessionProcessEventKind::Started => saw_started = true, - lash_core::SessionProcessEventKind::Cancelled => saw_cancelled = true, - } - } - AppEvent::RequestUiSnapshot => ui_refreshes += 1, - _ => {} - } - if saw_started && saw_cancelled && ui_refreshes >= 2 { - break; - } - } - assert!(saw_started, "expected process start event"); - assert!(saw_cancelled, "expected process cancellation event"); - assert!( - ui_refreshes >= 2, - "expected snapshot reconciliation requests for process changes" - ); -} - -async fn observation_test_session( - session_id: &str, - response: Option<&'static str>, - live_replay_capacity: Option, -) -> LashSession { - let provider = lash_core::testing::TestProvider::builder() - .kind("observation-test") - .complete(move |_| async move { - Ok(lash_core::LlmResponse { - full_text: response.unwrap_or_default().to_string(), - parts: Vec::new(), - usage: Default::default(), - terminal_reason: lash_core::LlmTerminalReason::Stop, - terminal_diagnostic: None, - provider_usage: None, - request_body: None, - http_summary: None, - execution_evidence: None, - response_metadata: Default::default(), - }) - }) - .build() - .into_handle(); - let mut builder = lash::LashCore::standard_builder() - .provider(provider) - .model( - lash_core::ModelSpec::from_token_limits( - "mock-model", - Default::default(), - 200_000, - None, - ) - .expect("model spec"), - ) - .effect_host(Arc::new(lash::durability::InlineEffectHost::default())) - .attachment_store(Arc::new(lash::persistence::InMemoryAttachmentStore::new())) - .process_registry(Arc::new(lash_core::TestLocalProcessRegistry::default())) - .process_env_store(Arc::new( - lash::persistence::InMemoryProcessExecutionEnvStore::new(), - )) - .store_factory(Arc::new( - lash::persistence::InMemorySessionStoreFactory::new(), - )); - if let Some(capacity) = live_replay_capacity { - builder = - builder.live_replay_store(Arc::new(lash_core::InMemoryLiveReplayStore::with_bounds( - capacity, - std::time::Duration::from_secs(120), - ))); - } - let core = builder.build().expect("core"); - core.session(session_id).open().await.expect("session") -} - -fn inline_scope(scope: lash_core::ExecutionScope) -> lash_core::ScopedEffectController<'static> { - lash_core::ScopedEffectController::shared( - Arc::new(lash_core::InlineRuntimeEffectController), - scope, - ) - .expect("inline execution scope") -} - -#[test] -fn completed_runtime_stream_rejects_late_activity() { - assert!(session_activity_is_current(7, 7, true)); - assert!(!session_activity_is_current(6, 7, true)); - assert!(!session_activity_is_current(7, 7, false)); -} - -#[test] -fn late_final_value_after_reconciliation_is_not_rendered_again() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.timeline = vec![ - UiTimelineItem::Splash, - UiTimelineItem::TurnStart(crate::app::Turn::user(false)), - UiTimelineItem::UserInput("hi".into()), - UiTimelineItem::LashlangCode("\nsubmit \"Hi! How can I help?\"".into()), - UiTimelineItem::AssistantText("Hi! How can I help?".into()), - ] - .into(); - - if session_activity_is_current(1, 1, false) { - app.handle_turn_activity(TurnActivity::independent(TurnEvent::FinalValue { - value: serde_json::json!("Hi! How can I help?"), - })); - } - - let assistant_texts: Vec<&str> = app - .timeline - .iter() - .filter_map(|block| match block { - UiTimelineItem::AssistantText(text) => Some(text.as_str()), - _ => None, - }) - .collect(); - assert_eq!(assistant_texts, vec!["Hi! How can I help?"]); -} - -#[test] -fn enter_on_command_suggestion_resolves_selected_slash_command() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.set_input("/in".into()); - app.update_suggestions(); - - let (text, parsed) = - super::input_handling::selected_slash_command_suggestion(&app, app.ui_extensions()) - .expect("selected slash command"); - - assert_eq!(text, "/info"); - assert!(matches!( - parsed, - super::commands::ParsedSlashCommand::Builtin(crate::command::Command::Info) - )); -} - -#[test] -fn enter_on_skill_suggestion_stays_text_completion() { - let root = - std::env::temp_dir().join(format!("lash-interactive-skill-{}", uuid::Uuid::new_v4())); - let skill_dir = root.join("localref"); - std::fs::create_dir_all(&skill_dir).expect("skill dir"); - std::fs::write( - skill_dir.join("SKILL.md"), - "---\nname: localref\ndescription: Clone a local reference\n---\n\nbody\n", - ) - .expect("skill file"); - - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.skills = crate::SkillCatalog::from_dirs(std::slice::from_ref(&root)); - app.set_input("/loc".into()); - app.update_suggestions(); - - assert!( - app.suggestions() - .iter() - .any(|suggestion| suggestion.name == "/localref") - ); - assert!( - super::input_handling::selected_slash_command_suggestion(&app, app.ui_extensions()) - .is_none() - ); - - app.complete_suggestion(); - assert_eq!(app.input(), "/localref "); -} - -#[test] -fn active_turn_slash_command_block_message_is_command_not_queue_language() { - let message = - super::input_handling::slash_command_blocked_while_working_message("/resume previous"); - - assert!(message.contains("Cannot run `/resume` while Lash is working")); - assert!(!message.to_ascii_lowercase().contains("queue")); -} - -#[test] -fn command_palette_items_include_current_settings_and_theme_choices() { - crate::theme::set_active_theme(crate::config::ThemeName::Lash); - let provider = lash_core::testing::TestProvider::builder() - .kind("codex") - .build() - .into_handle(); - let mut app = App::new("gpt-5.5".into(), "test".into(), "test-session-id".into()); - app.set_model_variant(Some("xhigh".to_string())); - app.set_execution_mode_label(&crate::execution_settings::ExecutionMode::Rlm); - - let items = super::input_handling::command_palette_items(&app, &provider); - - assert!(items.iter().any(|item| { - item.title == "Theme: Lash" - && item.current - && matches!( - item.action, - crate::overlay::CommandPaletteAction::Theme(crate::config::ThemeName::Lash) - ) - })); - assert!(items.iter().any(|item| { - item.title == "Theme: System" - && matches!( - item.action, - crate::overlay::CommandPaletteAction::Theme(crate::config::ThemeName::System) - ) - })); - assert!(items.iter().any(|item| { - item.title == "Model" - && item.description.contains("gpt-5.5") - && matches!( - item.action, - crate::overlay::CommandPaletteAction::InsertDraft(ref draft) if draft == "/model " - ) - })); - assert!(items.iter().any(|item| { - item.title == "Variant: xhigh" - && item.current - && matches!( - item.action, - crate::overlay::CommandPaletteAction::Builtin(crate::command::Command::Variant(Some(ref variant))) - if variant == "xhigh" - ) - })); - assert!(items.iter().any(|item| { - item.title == "Provider" - && item.description.contains("Codex") - && matches!( - item.action, - crate::overlay::CommandPaletteAction::Builtin( - crate::command::Command::ChangeProvider - ) - ) - })); -} - -#[test] -fn manual_interrupt_keeps_durable_queue_snapshot_out_of_history() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.timeline.push(UiTimelineItem::UserInput( - "(I want future migrations to work though!)".into(), - )); - app.test_seed_queued_turn_snapshot( - PreparedTurn::new("next queued thing".into(), Vec::new()), - lash_core::TurnInputIngress::NextTurn, - ); - - assert!(app.has_queued_messages()); - assert!(matches!( - app.timeline.last(), - Some(UiTimelineItem::UserInput(text)) if text == "(I want future migrations to work though!)" - )); -} - -#[test] -fn selection_is_preserved_for_modified_key_chords() { - let key = crossterm::event::KeyEvent { - code: crossterm::event::KeyCode::Char('c'), - modifiers: crossterm::event::KeyModifiers::SHIFT, - kind: crossterm::event::KeyEventKind::Press, - state: crossterm::event::KeyEventState::NONE, - }; - - assert!(should_preserve_selection_for_key(key)); -} - -#[test] -fn selection_is_cleared_for_plain_typing_keys() { - let key = crossterm::event::KeyEvent { - code: crossterm::event::KeyCode::Char('x'), - modifiers: crossterm::event::KeyModifiers::NONE, - kind: crossterm::event::KeyEventKind::Press, - state: crossterm::event::KeyEventState::NONE, - }; - - assert!(!should_preserve_selection_for_key(key)); -} - -#[test] -fn copy_shortcut_accepts_ctrl_shift_c() { - let key = crossterm::event::KeyEvent { - code: crossterm::event::KeyCode::Char('c'), - modifiers: crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::SHIFT, - kind: crossterm::event::KeyEventKind::Press, - state: crossterm::event::KeyEventState::NONE, - }; - - assert!(is_copy_shortcut(key)); -} - -#[test] -fn copy_shortcut_rejects_plain_ctrl_c() { - let key = crossterm::event::KeyEvent { - code: crossterm::event::KeyCode::Char('c'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - kind: crossterm::event::KeyEventKind::Press, - state: crossterm::event::KeyEventState::NONE, - }; - - assert!(!is_copy_shortcut(key)); -} - -#[test] -fn cleared_session_state_preserves_model_spec() { - let state = cleared_session_state(SessionPolicy { - model: lash_core::ModelSpec::from_token_limits( - "mock-model", - Default::default(), - 123_456, - None, - ) - .expect("valid model spec"), - ..SessionPolicy::default() - }); - - assert_eq!(state.policy.model.context_window_tokens(), 123_456); -} - -#[test] -fn osc52_sequence_wraps_for_tmux() { - let seq = osc52_sequence_for( - "YWJj", - &ClipboardEnv { - tmux: true, - ..ClipboardEnv::default() - }, - ); - assert!(seq.starts_with("\x1bPtmux;\x1b\x1b]52;c;YWJj\x07")); - assert!(seq.ends_with("\x1b\\")); -} - -#[test] -fn osc52_sequence_wraps_for_screen() { - let seq = osc52_sequence_for( - "YWJj", - &ClipboardEnv { - term: "screen-256color".to_string(), - ..ClipboardEnv::default() - }, - ); - assert_eq!(seq, "\x1bP\x1b]52;c;YWJj\x07\x1b\\"); -} - -#[test] -fn osc52_allowed_when_ssh_present() { - assert!(osc52_allowed_by_env(&ClipboardEnv { - ssh_tty: true, - ..ClipboardEnv::default() - })); -} - -#[test] -fn osc52_disallowed_for_unknown_terminal_without_ssh() { - assert!(!osc52_allowed_by_env(&ClipboardEnv { - term: "dumb".to_string(), - ..ClipboardEnv::default() - })); -} - -#[test] -fn pasted_text_input_uses_large_paste_placeholder_path() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); - - app.insert_pasted_text(&large); - app.update_suggestions(); - - assert_eq!(app.editor.pending_large_pastes.len(), 1); - assert!(app.input().contains("[Pasted Content")); -} - -#[test] -fn word_cursor_actions_move_across_word_boundaries() { - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.insert_text("alpha beta_gamma-delta omega"); - - app.editor.move_cursor_word_left(); - assert_eq!(&app.input()[app.cursor_pos()..], "omega"); - - app.editor.move_cursor_word_right(); - assert_eq!(app.cursor_pos(), app.input().len()); -} diff --git a/crates/lash-cli/src/lib.rs b/crates/lash-cli/src/lib.rs index 635d0cb..39073cb 100644 --- a/crates/lash-cli/src/lib.rs +++ b/crates/lash-cli/src/lib.rs @@ -4,4 +4,5 @@ //! and filesystem layout are host policy, not Lash runtime APIs. pub mod config; +pub mod execution_settings; pub mod paths; diff --git a/crates/lash-cli/src/main.rs b/crates/lash-cli/src/main.rs index 9e89dca..ad4cf9f 100644 --- a/crates/lash-cli/src/main.rs +++ b/crates/lash-cli/src/main.rs @@ -10,21 +10,22 @@ mod config; mod diff; mod editor; mod event; -mod examples; mod execution_settings; mod fork; mod host_docs; +mod host_policy; mod info; mod input_items; mod instructions; mod interactive; mod keybindings; mod markdown; +mod mcp_discovery; mod model_catalog; mod model_selection; mod overlay; mod paths; -mod persistence; +mod plan_plugin; mod plugin_surface; mod prompt_context_plugin; mod prompt_model; @@ -61,7 +62,7 @@ use lash::tracing::TraceLevel; #[cfg(test)] use lash::{InputItem, TurnActivity, TurnEvent}; -use execution_settings::RlmTerminationMode; +use execution_settings::{RlmDialect, RlmTerminationMode}; #[cfg(test)] use app::PreparedTurn; @@ -122,24 +123,24 @@ impl From for TraceLevel { #[global_allocator] static GLOBAL_ALLOCATOR: DhatAlloc = DhatAlloc; -fn turn_has_visible_output(turn: &lash::TurnResult) -> bool { +fn turn_has_visible_output(turn: &lash::TurnReport) -> bool { !turn.assistant_output.safe_text.trim().is_empty() || !turn.errors.is_empty() } fn completed_turn_should_warn_no_visible_output( - outcome: &lash_core::TurnOutcome, + outcome: &lash::TurnOutcome, result_has_visible_output: bool, active_turn_had_visible_output: bool, ) -> bool { matches!( outcome, - lash_core::TurnOutcome::Finished(_) | lash_core::TurnOutcome::AgentFrameSwitch { .. } + lash::TurnOutcome::Finished(_) | lash::TurnOutcome::AgentFrameSwitch { .. } ) && !result_has_visible_output && !active_turn_had_visible_output } pub(crate) fn turn_should_warn_no_visible_output( - turn: &lash::TurnResult, + turn: &lash::TurnReport, active_turn_had_visible_output: bool, ) -> bool { completed_turn_should_warn_no_visible_output( @@ -157,14 +158,6 @@ fn normalized_cli_args() -> Vec { } for arg in iter { if let Some(raw) = arg.to_str() { - if raw == "-ca" { - out.push("--context-approach".into()); - continue; - } - if let Some(value) = raw.strip_prefix("-ca=") { - out.push(format!("--context-approach={value}").into()); - continue; - } if raw == "-em" { out.push("--execution-mode".into()); continue; @@ -202,44 +195,9 @@ struct Args { #[arg(long = "execution-mode")] execution_mode: Option, - /// Standard-mode context approach (`rolling_history` or `observational_memory`) - #[arg(short = 'c', long = "context-approach", value_name = "APPROACH")] - standard_context_approach: Option, - - /// OM: observe once recent raw history reaches this many tokens - #[arg(long = "om-observation-message-tokens", value_name = "TOKENS")] - om_observation_message_tokens: Option, - - /// OM: keep this many recent raw-history tokens unobserved on the active tail - #[arg(long = "om-observation-buffer-tokens", value_name = "TOKENS")] - om_observation_buffer_tokens: Option, - - /// OM: hard ceiling for unobserved raw-history tokens before the tail is fully eligible - #[arg(long = "om-observation-block-after-tokens", value_name = "TOKENS")] - om_observation_block_after_tokens: Option, - - /// OM: maximum tokens per observer batch sent to the background worker - #[arg(long = "om-observation-max-tokens-per-batch", value_name = "TOKENS")] - om_observation_max_tokens_per_batch: Option, - - /// OM: how much prior memory text to carry into observer runs - #[arg(long = "om-previous-observer-tokens", value_name = "TOKENS")] - om_previous_observer_tokens: Option, - - /// OM: reflect once accumulated memory reaches this many tokens - #[arg(long = "om-reflection-observation-tokens", value_name = "TOKENS")] - om_reflection_observation_tokens: Option, - - /// OM: start reflection buffering at this percent of reflection tokens - #[arg( - long = "om-reflection-buffer-activation-percent", - value_name = "PERCENT" - )] - om_reflection_buffer_activation_percent: Option, - - /// OM: hard ceiling for reflection memory tokens - #[arg(long = "om-reflection-block-after-tokens", value_name = "TOKENS")] - om_reflection_block_after_tokens: Option, + /// RLM language pinned for the lifetime of the session + #[arg(long = "rlm-dialect", value_parser = execution_settings::parse_rlm_dialect)] + rlm_dialect: Option, /// RLM modes only: project a read-only bound variable from JSON for the autonomous turn, for example `--rlm-var input='{\"path\":\"src\"}'` #[arg(long = "rlm-var", value_name = "NAME=JSON")] @@ -273,7 +231,7 @@ struct Args { #[arg(long, value_name = "MS")] debug_ui_trace_interval_ms: Option, - /// Resume an existing session by id, name, or legacy .db filename + /// Resume an existing session by id or name (legacy .db files are refused) #[arg(long, value_name = "ID_OR_NAME")] resume: Option, @@ -285,7 +243,7 @@ struct Args { #[arg(long)] provider: bool, - /// Delete all lash data (~/.lash/ and ~/.cache/lash/) and exit + /// Delete the unified runtime store and session roster, then exit #[arg(long)] reset: bool, @@ -396,7 +354,15 @@ async fn run_export( out: Option<&std::path::Path>, ) -> anyhow::Result<()> { let format = lash_export::ExportFormat::parse(format)?; - let rendered = lash_export::export(std::path::Path::new(db), trace, format, out).await?; + let rendered = lash_export::export( + &crate::paths::store_dir(), + db, + &[db.to_string()], + trace, + format, + out, + ) + .await?; if out.is_none() { print!("{rendered}"); } @@ -630,7 +596,7 @@ mod tests { std::fs::write(&file_path, "x").expect("write temp file"); let original_cwd = std::env::current_dir().expect("cwd"); std::env::set_current_dir(&tmp_path).expect("chdir"); - let (items, image_blobs) = build_items_from_editor_input( + let items = build_items_from_editor_input( "before [Image #1] @a.txt after", vec![app::PendingImage { id: 1, @@ -644,7 +610,7 @@ mod tests { .iter() .map(|item| match item { InputItem::Text { .. } => "text", - InputItem::ImageRef { .. } => "image", + InputItem::Attachment { .. } => "image", }) .collect(); assert_eq!(kinds, vec!["text", "image", "text"]); @@ -654,12 +620,11 @@ mod tests { }; assert!(tail_text.contains("[file:")); assert!(tail_text.ends_with(" after")); - assert_eq!(image_blobs.len(), 1); } #[test] fn build_items_drops_removed_image_markers() { - let (items, image_blobs) = build_items_from_editor_input( + let items = build_items_from_editor_input( "before after", vec![app::PendingImage { id: 1, @@ -670,9 +635,8 @@ mod tests { assert!( items .iter() - .all(|item| !matches!(item, InputItem::ImageRef { .. })) + .all(|item| !matches!(item, InputItem::Attachment { .. })) ); - assert!(image_blobs.is_empty()); } #[test] @@ -688,7 +652,7 @@ mod tests { fn autonomous_renderer_prints_missing_final_tail_after_streamed_prefix() { let mut renderer = AutonomousRenderer::new(); let _ = renderer.handle(TurnActivity::independent(TurnEvent::AssistantProseDelta { - text: "Inspected files.\n".to_string(), + text: "Inspected files.\n".into(), })); renderer.finish_output("Inspected files.\nCompleted successfully."); @@ -701,7 +665,7 @@ mod tests { #[test] fn no_visible_output_warning_is_suppressed_after_live_output() { - let outcome = lash_core::TurnOutcome::Finished(lash_core::TurnFinish::FinalValue { + let outcome = lash::TurnOutcome::Finished(lash::TurnFinish::FinalValue { value: serde_json::Value::Null, }); @@ -840,7 +804,7 @@ mod tests { let message = make_injected_plugin_message(&turn).await; assert_eq!(message.role, MessageRole::User); - assert!(message.images.is_empty()); + assert!(message.attachments.is_empty()); assert_eq!(injected_image_part_indices(&message).len(), 2); assert!( message diff --git a/crates/lash-cli/src/examples/mcp_discovery/catalog.rs b/crates/lash-cli/src/mcp_discovery/catalog.rs similarity index 97% rename from crates/lash-cli/src/examples/mcp_discovery/catalog.rs rename to crates/lash-cli/src/mcp_discovery/catalog.rs index 66be256..9bd56b3 100644 --- a/crates/lash-cli/src/examples/mcp_discovery/catalog.rs +++ b/crates/lash-cli/src/mcp_discovery/catalog.rs @@ -1,3 +1,4 @@ +//! MCP catalog projection. use lash_core::CompactToolContract; use serde_json::{Value, json}; @@ -31,7 +32,7 @@ impl CatalogTool { // discovery fact this example needs. #[cfg(feature = "lashlang")] let (module_path, operation, call, aliases) = { - let binding: lash_lashlang_runtime::LashlangToolBinding = obj + let binding: lash_lashlang_runtime::ToolBinding = obj .get("bindings") .and_then(|bindings| bindings.get(lash_lashlang_runtime::LASHLANG_TOOL_BINDING_KEY)) .cloned() diff --git a/crates/lash-cli/src/examples/mcp_discovery/common.rs b/crates/lash-cli/src/mcp_discovery/common.rs similarity index 99% rename from crates/lash-cli/src/examples/mcp_discovery/common.rs rename to crates/lash-cli/src/mcp_discovery/common.rs index 676f2c0..ac848c9 100644 --- a/crates/lash-cli/src/examples/mcp_discovery/common.rs +++ b/crates/lash-cli/src/mcp_discovery/common.rs @@ -1,3 +1,4 @@ +//! Shared MCP discovery helpers. use std::collections::BTreeSet; use std::hash::{Hash, Hasher}; diff --git a/crates/lash-cli/src/examples/mcp_discovery/definitions.rs b/crates/lash-cli/src/mcp_discovery/definitions.rs similarity index 75% rename from crates/lash-cli/src/examples/mcp_discovery/definitions.rs rename to crates/lash-cli/src/mcp_discovery/definitions.rs index fc130cb..702699f 100644 --- a/crates/lash-cli/src/examples/mcp_discovery/definitions.rs +++ b/crates/lash-cli/src/mcp_discovery/definitions.rs @@ -1,5 +1,6 @@ -use lash_core::{ToolActivation, ToolDefinition, ToolScheduling}; -use lash_tool_support::{LashlangToolBinding, ToolDefinitionLashlangExt}; +//! Deferred MCP tool definitions. +use lash_core::{ToolActivation, ToolDefinition}; +use lash_tool_support::{ToolBinding, ToolDefinitionBindingExt}; use serde_json::{Value, json}; pub(crate) fn search_tools_definition() -> ToolDefinition { @@ -37,11 +38,7 @@ pub(crate) fn search_tools_definition() -> ToolDefinition { Many(Vec), } - let description = if cfg!(feature = "lashlang") { - "Search catalogued module capabilities, aliases, descriptions, signatures, return fields, and examples. Use this when the capability you need is only listed in the catalogued-capabilities preview or is too sparse to call confidently. Query with concise keywords and short intent phrases: include the app/domain, action, object, qualifiers, and important fields or constraints. For initial exploration, print only result call paths and signatures; inspect descriptions and examples only when you need to choose between close matches or learn call idioms." - } else { - "Search catalogued tools by name, id, description, signatures, return fields, and examples. Use this when the capability you need is only listed in the catalogued-capabilities preview or is too sparse to call confidently. Query with concise keywords and short intent phrases: include the app/domain, action, object, qualifiers, and important fields or constraints." - }; + let description = "Search catalogued capabilities, aliases, descriptions, signatures, return fields, and examples. Use this when the capability you need is only listed in the catalogued-capabilities preview or is too sparse to call confidently. Query with concise keywords and short intent phrases: include the app/domain, action, object, qualifiers, and important fields or constraints."; ToolDefinition::raw( "tool:search_tools", @@ -50,18 +47,8 @@ pub(crate) fn search_tools_definition() -> ToolDefinition { schema_for::(), search_tools_output_schema(), ) - .with_examples(vec![ - "await tools.search({ query: \"spotify liked songs library\" })?".into(), - "await tools.search({ query: \"spotify song details play_count genre title song_id\" })?" - .into(), - "await tools.search({ query: \"venmo send money private payment_card receiver_email\" })?" - .into(), - ]) .with_activation(ToolActivation::Always) - .with_lashlang_binding( - LashlangToolBinding::new(["tools"], "search").with_aliases(["tool_search"]), - ) - .with_scheduling(ToolScheduling::Serial) + .with_tool_binding(ToolBinding::new(["tools"], "search").with_aliases(["tool_search"])) } fn schema_for() -> Value @@ -120,7 +107,7 @@ fn search_tools_output_schema() -> Value { "call".to_string(), json!({ "type": "string", - "description": "Exact legal Lashlang module call path." + "description": "Exact callable module path for the active RLM dialect." }), ); schema @@ -161,7 +148,7 @@ mod tests { ); #[cfg(feature = "lashlang")] assert!( - rendered_signature.contains("module?: str | list[str] | null"), + rendered_signature.contains("module?: list[str] | str | null"), "{rendered_signature}" ); #[cfg(not(feature = "lashlang"))] @@ -170,7 +157,7 @@ mod tests { "{rendered_signature}" ); assert!( - rendered_signature.contains("exclude?: str | list[str] | null"), + rendered_signature.contains("exclude?: list[str] | str | null"), "{rendered_signature}" ); assert!( diff --git a/crates/lash-cli/src/mcp_discovery/mod.rs b/crates/lash-cli/src/mcp_discovery/mod.rs new file mode 100644 index 0000000..d880241 --- /dev/null +++ b/crates/lash-cli/src/mcp_discovery/mod.rs @@ -0,0 +1,73 @@ +//! MCP tool-discovery support owned by the CLI host. +//! +//! This is host policy, not a lash primitive: lash ships no tool discovery. The +//! example shows the recommended way to make a large MCP tool set discoverable +//! under the flat Tool Catalog + RLM deferred-resolution model: +//! +//! 1. Enumerate MCP tools and build a ranking index ([`ranking`]). +//! 2. Advertise them through a catalogue-preview prompt contribution +//! ([`lash_lashlang_runtime::catalogue_preview_contribution`]). +//! 3. Expose a `search_tools` host tool over the index ([`definitions`], +//! [`service`]). +//! 4. Register a [`DeferredToolResolver`](lash_lashlang_runtime::DeferredToolResolver) +//! that resolves chosen MCP call-paths into a Tool Grant + Tool Execution +//! Binding ([`resolver`]). +//! +//! The index ranking (BM25 / optional semantic / RRF) lives here as a reference +//! example hosts can copy or adapt. The catalogue-preview formatter is a public +//! helper in `lash-lashlang-runtime`. + +mod catalog; +mod common; +mod definitions; +mod plugin; +mod ranking; +mod rerank; +mod resolver; +mod schema_index; +mod service; + +pub use plugin::ToolDiscoveryPluginFactory; +pub use resolver::{McpCatalogedTool, McpDeferredToolResolver}; + +use std::sync::Arc; + +use lash_core::ToolProvider; +use serde_json::json; + +/// Enumerate provider tools into the shared MCP discovery/deferred record. +pub fn mcp_cataloged_tools(server: &str, provider: &dyn ToolProvider) -> Vec { + provider + .tool_manifests() + .into_iter() + .filter_map(|manifest| { + let contract = provider.resolve_contract(&manifest.name)?; + Some(McpCatalogedTool { + server: server.to_string(), + definition: lash_core::ToolDefinition::from_parts( + manifest, + Arc::unwrap_or_clone(contract), + ), + }) + }) + .collect() +} + +/// Project enumerated MCP tools to the JSON catalog consumed by `search_tools` +/// and the catalogue-preview prompt contribution. +pub fn mcp_catalog_records(tools: &[McpCatalogedTool]) -> Vec { + tools + .iter() + .map(|tool| { + let manifest = tool.definition.manifest(); + json!({ + "id": manifest.id, + "name": manifest.name, + "description": manifest.description, + "bindings": manifest.bindings, + "activation": manifest.activation, + "contract": manifest.compact_contract, + }) + }) + .collect() +} diff --git a/crates/lash-cli/src/mcp_discovery/plugin.rs b/crates/lash-cli/src/mcp_discovery/plugin.rs new file mode 100644 index 0000000..ffad5fa --- /dev/null +++ b/crates/lash-cli/src/mcp_discovery/plugin.rs @@ -0,0 +1,74 @@ +//! MCP discovery plugin. +use std::sync::Arc; + +use lash_core::ToolProvider; +use lash_core::plugin::{ + PluginError, PluginFactory, PluginSessionContext, PluginSpec, SessionPlugin, + StaticPluginFactory, +}; +use lash_lashlang_runtime::catalogue_preview_contribution; + +use super::service::tool_discovery_provider_with_catalog; + +/// Plugin factory for the `search_tools` deferred-discovery catalog. +/// +/// Declares its provider through a [`PluginSpec`] driven by +/// [`StaticPluginFactory`], so it does not hand-roll the `SessionPlugin` + +/// `register` ceremony. Alongside the `search_tools` tool, it advertises the +/// deferred (non-resident) MCP tools to the model through a catalogue-preview +/// prompt contribution. `with_catalog(...)` is the CLI/RLM path for a large MCP +/// tail: `search_tools` indexes resident tools plus that explicit tail, while +/// the prompt preview lists only the deferred catalog so resident members keep +/// their full RLM docs. +pub struct ToolDiscoveryPluginFactory { + inner: StaticPluginFactory, +} + +impl ToolDiscoveryPluginFactory { + pub fn new() -> Self { + Self::with_catalog(Vec::new()) + } + + pub fn with_catalog(extra_catalog: Vec) -> Self { + if extra_catalog.is_empty() { + return Self { + inner: StaticPluginFactory::new("tool_discovery", PluginSpec::new()), + }; + } + let extra_catalog = Arc::new(extra_catalog); + let discovery_catalog = Arc::clone(&extra_catalog); + let spec = PluginSpec::new() + .with_tool_provider(Arc::new(tool_discovery_provider_with_catalog( + extra_catalog.as_ref().clone(), + )) as Arc) + .with_prompt_contributor(Arc::new( + move |_ctx: lash_core::plugin::PromptHookContext| { + let discovery_catalog = Arc::clone(&discovery_catalog); + Box::pin(async move { + Ok(catalogue_preview_contribution(discovery_catalog.as_ref()) + .into_iter() + .collect()) + }) + }, + )); + Self { + inner: StaticPluginFactory::new("tool_discovery", spec), + } + } +} + +impl Default for ToolDiscoveryPluginFactory { + fn default() -> Self { + Self::new() + } +} + +impl PluginFactory for ToolDiscoveryPluginFactory { + fn id(&self) -> &'static str { + self.inner.id() + } + + fn build(&self, ctx: &PluginSessionContext) -> Result, PluginError> { + self.inner.build(ctx) + } +} diff --git a/crates/lash-cli/src/examples/mcp_discovery/ranking.rs b/crates/lash-cli/src/mcp_discovery/ranking.rs similarity index 61% rename from crates/lash-cli/src/examples/mcp_discovery/ranking.rs rename to crates/lash-cli/src/mcp_discovery/ranking.rs index 0cff9e5..b50bf1b 100644 --- a/crates/lash-cli/src/examples/mcp_discovery/ranking.rs +++ b/crates/lash-cli/src/mcp_discovery/ranking.rs @@ -1,3 +1,4 @@ +//! MCP discovery ranking. use std::collections::{BTreeSet, HashMap}; #[cfg(feature = "semantic-tool-search")] use std::sync::{Mutex, OnceLock}; @@ -302,10 +303,7 @@ impl DiscoveryDoc { push_field( &mut fields, "output_fields", - vec![ - compact_values_index_text(&tool.contract.return_fields), - tool.contract.render_returns(), - ], + vec![compact_values_index_text(&tool.contract.return_fields)], 2.4, false, ); @@ -617,338 +615,3 @@ fn cosine_similarity(left: &[f32], right: &[f32]) -> f32 { dot / (left_norm.sqrt() * right_norm.sqrt()) } } - -#[cfg(test)] -mod tests { - use super::*; - use lash_core::{ToolContract, ToolDefinition}; - use lash_tool_support::{LashlangToolBinding, ToolDefinitionLashlangExt}; - use serde_json::{Value, json}; - - fn catalog_tool(name: &str, description: &str) -> Value { - catalog_tool_with_metadata(name, description, Some("tools"), Vec::new()) - } - - fn catalog_tool_with_metadata( - name: &str, - description: &str, - module: Option<&str>, - aliases: Vec<&str>, - ) -> Value { - let tool = ToolDefinition::raw( - format!("tool:test/{name}"), - name, - description, - ToolContract::default_input_schema(), - json!({}), - ) - .with_lashlang_binding( - LashlangToolBinding::new( - [module.unwrap_or(match name { - "read_file" => "files", - "search_web" => "web", - _ => "tools", - })], - match name { - "read_file" => "read", - "search_web" => "search", - _ => name, - }, - ) - .with_aliases(aliases), - ); - catalog_tool_from_definition(tool) - } - - fn catalog_tool_from_definition(tool: ToolDefinition) -> Value { - // Mirror the flat `project_tool_catalog` shape: id/name/description/ - // bindings/activation/contract. The Lashlang call-path is derived from - // the `lashlang.tool` binding by `CatalogTool::from_value`. - let manifest = tool.manifest(); - json!({ - "id": manifest.id, - "name": manifest.name, - "description": manifest.description, - "bindings": manifest.bindings, - "activation": manifest.activation, - "contract": manifest.compact_contract.clone().expect("compact contract"), - }) - } - - fn ranked_names(results: &[Value]) -> Vec { - results - .iter() - .map(|result| { - result - .get("name") - .and_then(Value::as_str) - .expect("ranked result name") - .to_string() - }) - .collect() - } - - #[test] - fn exact_name_beats_fuzzy_typo() { - let index = ToolDiscoveryIndex::build( - 1, - &[ - catalog_tool("spotify_search_songs", "Find songs in Spotify"), - catalog_tool("spotty_notes", "Scratch notes"), - ], - ); - let results = index.search(&json!({ "query": "spotify songs" })); - assert_eq!(results[0]["name"], json!("spotify_search_songs")); - let typo = index.search(&json!({ "query": "spotfy songs" })); - assert_eq!(typo[0]["name"], json!("spotify_search_songs")); - } - - #[test] - fn ranking_prefers_output_fields_over_input_filter_matches() { - let filter_songs = ToolDefinition::raw( - "mcp:appworld/mcp__appworld__spotify_filter_songs", - "mcp__appworld__spotify_filter_songs", - "Search Spotify songs by filters.", - json!({ - "type": "object", - "properties": { - "access_token": {"type": "string"}, - "genre": { - "type": "string", - "description": "Genre filter." - }, - "play_count": { - "type": "integer", - "description": "Minimum play count filter." - }, - "title": { - "type": "string", - "description": "Title filter." - } - }, - "required": ["access_token"] - }), - json!({ - "type": "object", - "properties": { - "response": { - "type": "array", - "items": { - "type": "object", - "properties": { - "song_id": {"type": "integer"} - }, - "required": ["song_id"] - } - } - }, - "required": ["response"] - }), - ) - .with_lashlang_binding( - LashlangToolBinding::new(["appworld"], "spotify_filter_songs") - .with_aliases(["spotify_filter_songs"]), - ); - let show_song = ToolDefinition::raw( - "mcp:appworld/mcp__appworld__spotify_show_song", - "mcp__appworld__spotify_show_song", - "Get a Spotify song record.", - json!({ - "type": "object", - "properties": { - "access_token": {"type": "string"}, - "song_id": {"type": "integer"} - }, - "required": ["access_token", "song_id"] - }), - json!({ - "type": "object", - "properties": { - "response": { - "type": "object", - "description": "Detailed song record.", - "properties": { - "genre": { - "type": "string", - "description": "Song genre." - }, - "play_count": { - "type": "integer", - "description": "Number of times the song was played." - }, - "title": { - "type": "string", - "description": "Song title." - } - }, - "required": ["genre", "play_count", "title"] - } - }, - "required": ["response"] - }), - ) - .with_lashlang_binding( - LashlangToolBinding::new(["appworld"], "spotify_show_song") - .with_aliases(["spotify_show_song", "song_details"]), - ); - - let index = ToolDiscoveryIndex::build( - 1, - &[ - catalog_tool_from_definition(filter_songs), - catalog_tool_from_definition(show_song), - ], - ); - let results = index.search(&json!({ - "query": "play_count genre title", - "module": "appworld" - })); - - assert_eq!( - results[0]["name"], - json!("mcp__appworld__spotify_show_song") - ); - } - - #[test] - fn search_results_include_compact_schema_parameter_restrictions() { - let spotify = ToolDefinition::raw( - "mcp:appworld/mcp__appworld__spotify_search_songs", - "mcp__appworld__spotify_search_songs", - "Find songs", - json!({ - "type": "object", - "properties": { - "access_token": { - "type": "string", - "description": "Access token obtained from spotify app login." - }, - "genre": { - "type": ["string", "null"], - "description": "Only include songs from this genre.", - "default": null - }, - "page_limit": { - "type": "integer", - "description": "Maximum number of results to return.", - "minimum": 1, - "maximum": 20, - "default": 20 - } - }, - "required": ["access_token"] - }), - json!({ - "type": "object", - "properties": { - "response": { - "type": "array", - "description": "Matched songs.", - "items": { - "type": "object", - "properties": { - "play_count": { - "type": "integer", - "description": "Number of times the song was played.", - "minimum": 0 - }, - "song_id": { - "type": "integer", - "description": "Stable song identifier." - }, - "title": { - "type": "string", - "description": "Song title." - } - }, - "required": ["play_count", "song_id", "title"] - } - } - }, - "required": ["response"] - }), - ) - .with_examples(vec![ - "search songs by genre".to_string(), - "search songs by play count".to_string(), - ]) - .with_lashlang_binding( - LashlangToolBinding::new(["appworld"], "spotify_search_songs") - .with_aliases(["spotify_search_songs"]), - ); - let index = ToolDiscoveryIndex::build(1, &[catalog_tool_from_definition(spotify)]); - - let results = index.search(&json!({ "query": "spotify" })); - let signature = results[0]["signature"].as_str().expect("signature"); - assert!(signature.contains("page_limit?: int >= 1 <= 20 = 20")); - assert!(signature.contains("response[].play_count: int >= 0")); - assert_eq!( - results[0]["examples"], - json!(["search songs by genre", "search songs by play count"]) - ); - assert!(results[0].get("input_schema").is_none()); - assert!(results[0].get("output_schema").is_none()); - } - - #[test] - fn reciprocal_rank_fusion_keeps_cross_list_hits_ahead_of_single_list_noise() { - let fused = reciprocal_rank_fusion( - vec![ - RankedCandidate { - idx: 0, - lexical_score: 10.0, - semantic_score: None, - }, - RankedCandidate { - idx: 1, - lexical_score: 8.0, - semantic_score: None, - }, - RankedCandidate { - idx: 2, - lexical_score: 6.0, - semantic_score: None, - }, - ], - vec![ - RankedCandidate { - idx: 3, - lexical_score: 0.0, - semantic_score: Some(0.99), - }, - RankedCandidate { - idx: 1, - lexical_score: 0.0, - semantic_score: Some(0.88), - }, - RankedCandidate { - idx: 4, - lexical_score: 0.0, - semantic_score: Some(0.87), - }, - ], - ); - - let names = fused - .iter() - .map(|candidate| candidate.idx) - .collect::>(); - assert_eq!(names[..3], [1, 0, 3]); - } - - #[test] - fn ranked_names_extracts_result_names() { - let index = ToolDiscoveryIndex::build( - 1, - &[ - catalog_tool_with_metadata("read_file", "Read file contents", None, vec!["cat"]), - catalog_tool_with_metadata("search_web", "Search the web", None, vec!["web"]), - ], - ); - - assert_eq!( - ranked_names(&index.search(&json!({ "query": "read file" }))), - vec!["read_file"] - ); - } -} diff --git a/crates/lash-cli/src/examples/mcp_discovery/rerank.rs b/crates/lash-cli/src/mcp_discovery/rerank.rs similarity index 68% rename from crates/lash-cli/src/examples/mcp_discovery/rerank.rs rename to crates/lash-cli/src/mcp_discovery/rerank.rs index 76d2923..699726f 100644 --- a/crates/lash-cli/src/examples/mcp_discovery/rerank.rs +++ b/crates/lash-cli/src/mcp_discovery/rerank.rs @@ -1,6 +1,7 @@ +//! Optional semantic reranking. use std::collections::{BTreeMap, BTreeSet}; -use lash_core::{ +use lash::direct::{ DirectJsonSchema, DirectMessage, DirectOutputSpec, DirectPart, DirectRequest, DirectRole, }; use serde_json::{Value, json}; @@ -247,125 +248,3 @@ pub(crate) fn merge_llm_selection( } ranked } - -#[cfg(test)] -mod tests { - use super::*; - use lash_core::{DirectOutputSpec, DirectPart}; - - fn ranked_names(results: &[Value]) -> Vec { - results - .iter() - .map(|result| { - result - .get("name") - .and_then(Value::as_str) - .expect("ranked result name") - .to_string() - }) - .collect() - } - - #[test] - fn llm_rerank_request_uses_structured_name_enum_schema() { - let candidates = vec![ - json!({"name": "read_file", "signature": "await files.read({ path: str })? -> str", "description": "Read file"}), - json!({"name": "search_web", "signature": "await web.search({ query: str })? -> record", "description": "Search web"}), - ]; - - let request = llm_rerank_request( - &json!({"query": "find docs"}), - &candidates, - 2, - "parent-model".to_string(), - Some("parent-variant".to_string()), - ); - - assert_eq!(request.model, "parent-model"); - assert_eq!(request.model_variant.effort(), Some("parent-variant")); - let DirectOutputSpec::JsonSchema(schema) = request.output else { - panic!("expected json schema output"); - }; - assert_eq!(schema.name, "tool_search_rerank"); - assert_eq!( - schema.schema.canonical["properties"]["tool_names"]["items"]["enum"], - json!(["read_file", "search_web"]) - ); - } - - #[test] - fn payment_action_intent_promotes_transaction_tool_and_demotes_reminder() { - let candidates = vec![ - json!({"name": "venmo.create_reminder", "description": "Create a payment reminder"}), - json!({"name": "venmo.create_transaction", "description": "Send money to another user"}), - json!({"name": "venmo.show_balance", "description": "Show the venmo balance"}), - ]; - - let reranked = rerank_payment_action_intent("send money to a friend", candidates); - - assert_eq!( - ranked_names(&reranked), - vec![ - "venmo.create_transaction", - "venmo.show_balance", - "venmo.create_reminder", - ] - ); - } - - #[test] - fn payment_action_intent_noop_without_payment_query() { - let candidates = vec![ - json!({"name": "venmo.create_reminder", "description": "Create a payment reminder"}), - json!({"name": "venmo.create_transaction", "description": "Send money to another user"}), - ]; - - let reranked = rerank_payment_action_intent("list reminders", candidates.clone()); - - assert_eq!(reranked, candidates); - } - - #[test] - fn merge_llm_selection_dedupes_and_fills_from_deterministic_order() { - let candidates = vec![ - json!({"name": "a"}), - json!({"name": "b"}), - json!({"name": "c"}), - ]; - - let merged = merge_llm_selection( - candidates, - vec!["b".to_string(), "b".to_string(), "missing".to_string()], - 3, - ); - - assert_eq!(ranked_names(&merged), vec!["b", "a", "c"]); - } - - #[test] - fn llm_rerank_prompt_excludes_filtered_candidates() { - let candidates = vec![ - json!({"name": "search_web", "signature": "await web.search({ query: str })? -> record"}), - ]; - let request = llm_rerank_request( - &json!({"query": "", "exclude": ["read_file"]}), - &candidates, - 1, - "parent-model".to_string(), - None, - ); - - assert!( - request - .messages - .iter() - .flat_map(|message| message.parts.iter()) - .any(|part| matches!( - part, - DirectPart::Text(text) - if text.contains("\"exclude\":[\"read_file\"]") - && !text.contains("\"name\":\"read_file\"") - )) - ); - } -} diff --git a/crates/lash-cli/src/examples/mcp_discovery/resolver.rs b/crates/lash-cli/src/mcp_discovery/resolver.rs similarity index 62% rename from crates/lash-cli/src/examples/mcp_discovery/resolver.rs rename to crates/lash-cli/src/mcp_discovery/resolver.rs index 8da6354..4ffea7f 100644 --- a/crates/lash-cli/src/examples/mcp_discovery/resolver.rs +++ b/crates/lash-cli/src/mcp_discovery/resolver.rs @@ -1,4 +1,4 @@ -//! Reference [`DeferredToolResolver`] for MCP call-paths. +//! [`DeferredToolResolver`] for MCP call-paths. //! //! When the model calls a Lashlang module path that is not resident in the //! link-time host environment, RLM linking asks this resolver to resolve it. @@ -75,53 +75,3 @@ impl DeferredToolResolver for McpDeferredToolResolver { .collect() } } - -#[cfg(test)] -mod tests { - use super::*; - use lash_core::ToolContract; - use lash_tool_support::{LashlangToolBinding, ToolDefinitionLashlangExt}; - - fn mcp_tool(server: &str, name: &str, module: &str, operation: &str) -> McpCatalogedTool { - let definition = ToolDefinition::raw( - format!("tool:{server}/{name}"), - name, - format!("MCP tool {name}"), - ToolContract::default_input_schema(), - serde_json::json!({ "type": "object" }), - ) - .with_lashlang_binding(LashlangToolBinding::new([module], operation)); - McpCatalogedTool { - server: server.to_string(), - definition, - } - } - - #[tokio::test] - async fn resolves_known_call_path_with_execution_binding() { - let resolver = - McpDeferredToolResolver::new([mcp_tool("appworld", "venmo_send", "venmo", "send")]); - let mut resolutions = resolver.resolve(&["venmo.send"]).await; - let resolution = resolutions.remove("venmo.send").unwrap(); - let Resolution::Resolved(grant) = resolution else { - panic!("expected resolved grant"); - }; - assert_eq!(grant.execution_binding["kind"], json!("mcp")); - assert_eq!(grant.execution_binding["server"], json!("appworld")); - assert_eq!( - grant.source_id.as_deref(), - Some(lash::tools::PLUGIN_TOOL_SOURCE_ID) - ); - assert_eq!(grant.definition.name(), "venmo_send"); - } - - #[tokio::test] - async fn unknown_call_path_is_not_available() { - let resolver = McpDeferredToolResolver::new(std::iter::empty()); - let mut resolutions = resolver.resolve(&["mystery.run"]).await; - assert!(matches!( - resolutions.remove("mystery.run"), - Some(Resolution::NotAvailable) - )); - } -} diff --git a/crates/lash-cli/src/examples/mcp_discovery/schema_index.rs b/crates/lash-cli/src/mcp_discovery/schema_index.rs similarity index 94% rename from crates/lash-cli/src/examples/mcp_discovery/schema_index.rs rename to crates/lash-cli/src/mcp_discovery/schema_index.rs index 8670b49..49afbd9 100644 --- a/crates/lash-cli/src/examples/mcp_discovery/schema_index.rs +++ b/crates/lash-cli/src/mcp_discovery/schema_index.rs @@ -1,3 +1,4 @@ +//! Schema indexing for MCP discovery. #[cfg(feature = "semantic-tool-search")] use super::catalog::CatalogTool; diff --git a/crates/lash-cli/src/mcp_discovery/service.rs b/crates/lash-cli/src/mcp_discovery/service.rs new file mode 100644 index 0000000..b8fdeae --- /dev/null +++ b/crates/lash-cli/src/mcp_discovery/service.rs @@ -0,0 +1,188 @@ +//! MCP discovery service. +use std::sync::{Arc, RwLock}; + +use lash_core::{AttemptContext, ToolCall, ToolOutcome}; +use lash_tool_support::{StaticToolExecute, StaticToolProvider}; +use serde_json::{Value, json}; + +use super::common::{LLM_CANDIDATE_LIMIT, args_with_limit, catalog_key, limit_from_args}; +use super::definitions::search_tools_definition; +use super::ranking::ToolDiscoveryIndex; +use super::rerank::{ + llm_rerank_request, merge_llm_selection, parse_llm_tool_names, rerank_payment_action_intent, +}; + +#[derive(Clone, Default)] +struct IndexCache { + index: Option, +} + +#[derive(Clone)] +struct CachedIndex { + catalog_addr: usize, + catalog_len: usize, + index: Arc, +} + +impl CachedIndex { + fn matches_catalog_arc(&self, catalog: &Arc>) -> bool { + self.catalog_addr == Arc::as_ptr(catalog) as usize && self.catalog_len == catalog.len() + } +} + +#[derive(Clone)] +pub struct ToolDiscoveryToolsProvider { + cache: Arc>, + extra_catalog: Arc>, +} + +impl Default for ToolDiscoveryToolsProvider { + fn default() -> Self { + Self::new() + } +} + +impl ToolDiscoveryToolsProvider { + pub fn new() -> Self { + Self::with_catalog(Vec::new()) + } + + pub fn with_catalog(extra_catalog: Vec) -> Self { + Self { + cache: Arc::default(), + extra_catalog: Arc::new(extra_catalog), + } + } + + fn searchable_catalog(&self, resident_catalog: Arc>) -> Arc> { + if self.extra_catalog.is_empty() { + return resident_catalog; + } + let mut combined = Vec::with_capacity(resident_catalog.len() + self.extra_catalog.len()); + combined.extend(resident_catalog.iter().cloned()); + combined.extend(self.extra_catalog.iter().cloned()); + Arc::new(combined) + } + + fn index_for_catalog(&self, catalog: Arc>) -> Arc { + if let Some(index) = self + .cache + .read() + .expect("tool discovery cache lock poisoned") + .index + .as_ref() + .filter(|cached| cached.matches_catalog_arc(&catalog)) + .map(|cached| Arc::clone(&cached.index)) + { + return index; + } + + let key = catalog_key(catalog.as_ref()); + if let Some(index) = self + .cache + .read() + .expect("tool discovery cache lock poisoned") + .index + .as_ref() + .filter(|cached| cached.index.key == key) + .map(|cached| Arc::clone(&cached.index)) + { + return index; + } + + let index = Arc::new(ToolDiscoveryIndex::build(key, catalog.as_ref())); + let cached = CachedIndex { + catalog_addr: Arc::as_ptr(&catalog) as usize, + catalog_len: catalog.len(), + index: Arc::clone(&index), + }; + self.cache + .write() + .expect("tool discovery cache lock poisoned") + .index = Some(cached); + index + } + + async fn search_tools( + &self, + args: &Value, + resident_catalog: Arc>, + context: &AttemptContext<'_>, + ) -> ToolOutcome { + let catalog = self.searchable_catalog(resident_catalog); + let index = self.index_for_catalog(catalog); + let limit = limit_from_args(args); + let candidate_args = args_with_limit(args, LLM_CANDIDATE_LIMIT); + let candidates = index.search(&candidate_args); + if candidates.is_empty() { + return ToolOutcome::ok(json!([])); + } + let query = args + .get("query") + .and_then(Value::as_str) + .unwrap_or_default(); + let candidates = rerank_payment_action_intent(query, candidates); + + let model = match context.sessions().model().await { + Ok(model) => model, + Err(err) => { + return ToolOutcome::err_fmt(format_args!( + "search_tools could not resolve parent model: {err}" + )); + } + }; + let request = llm_rerank_request( + args, + &candidates, + limit, + model.model, + crate::model_selection::variant_from_reasoning_selection(model.model_variant), + ); + let completion = match context + .direct_completions() + .complete(request, "search_tools") + .await + { + Ok(completion) => completion, + Err(err) => return ToolOutcome::err_fmt(format_args!("search_tools failed: {err}")), + }; + + let selected_names = match parse_llm_tool_names(&completion.text) { + Ok(names) => names, + Err(err) => { + return ToolOutcome::err_fmt(format_args!( + "search_tools returned invalid JSON: {err}" + )); + } + }; + + ToolOutcome::ok(json!(merge_llm_selection( + candidates, + selected_names, + limit + ))) + } +} + +/// Build the `search_tools` provider with an additional non-resident catalog. +pub fn tool_discovery_provider_with_catalog( + extra_catalog: Vec, +) -> StaticToolProvider { + StaticToolProvider::new( + vec![search_tools_definition()], + ToolDiscoveryToolsProvider::with_catalog(extra_catalog), + ) +} + +#[async_trait::async_trait] +impl StaticToolExecute for ToolDiscoveryToolsProvider { + async fn execute(&self, call: ToolCall<'_>) -> ToolOutcome { + match call.name { + "search_tools" => match call.context.sessions().shared_tool_catalog().await { + Ok(catalog) => self.search_tools(call.args, catalog, call.context).await, + Err(err) => ToolOutcome::err_fmt(err.to_string()), + }, + _ => ToolOutcome::err_fmt(format_args!("Unknown tool: {}", call.name)), + } + } +} diff --git a/crates/lash-cli/src/model_catalog.rs b/crates/lash-cli/src/model_catalog.rs index 0008cb1..d0598ea 100644 --- a/crates/lash-cli/src/model_catalog.rs +++ b/crates/lash-cli/src/model_catalog.rs @@ -33,18 +33,21 @@ impl ModelInfo { id: impl Into, variant: Option, ) -> Result { - ModelSpec::from_token_limits( - id, - crate::model_selection::reasoning_selection_from_variant(variant), - usize::try_from(self.prompt_budget_tokens()) - .map_err(|_| "prompt budget does not fit in usize".to_string())?, - self.max_output_tokens - .map(|value| { - usize::try_from(value) - .map_err(|_| "output token capacity does not fit in usize".to_string()) - }) - .transpose()?, - ) + let mut builder = ModelSpec::builder(id) + .variant(crate::model_selection::reasoning_selection_from_variant( + variant, + )) + .context_window_tokens( + usize::try_from(self.prompt_budget_tokens()) + .map_err(|_| "prompt budget does not fit in usize".to_string())?, + ); + if let Some(output_capacity) = self.max_output_tokens { + builder = builder.output_token_capacity( + usize::try_from(output_capacity) + .map_err(|_| "output token capacity does not fit in usize".to_string())?, + ); + } + builder.build().map_err(|error| error.to_string()) } } @@ -141,51 +144,6 @@ pub(crate) trait ModelCatalogStore: Send + Sync { fn modified_at(&self) -> Result, String>; } -#[cfg(test)] -#[derive(Clone, Debug, Default)] -pub(crate) struct MemoryModelCatalogStore { - raw: Arc>>, - modified_at: Arc>>, -} - -#[cfg(test)] -impl MemoryModelCatalogStore { - pub(crate) fn new(raw: Option) -> Self { - Self { - raw: Arc::new(RwLock::new(raw)), - modified_at: Arc::new(RwLock::new(None)), - } - } -} - -#[cfg(test)] -impl ModelCatalogStore for MemoryModelCatalogStore { - fn load(&self) -> Result, String> { - self.raw - .read() - .map(|raw| raw.clone()) - .map_err(|_| "model catalog memory store lock poisoned".to_string()) - } - - fn save(&self, raw: &str) -> Result<(), String> { - self.raw - .write() - .map_err(|_| "model catalog memory store lock poisoned".to_string()) - .map(|mut slot| *slot = Some(raw.to_string()))?; - self.modified_at - .write() - .map_err(|_| "model catalog memory store lock poisoned".to_string()) - .map(|mut slot| *slot = Some(SystemTime::now())) - } - - fn modified_at(&self) -> Result, String> { - self.modified_at - .read() - .map(|value| *value) - .map_err(|_| "model catalog memory store lock poisoned".to_string()) - } -} - #[async_trait] pub(crate) trait ModelCatalogSource: Send + Sync { async fn fetch(&self) -> Result; diff --git a/crates/lash-cli/src/model_selection.rs b/crates/lash-cli/src/model_selection.rs index 93816ec..6064c0c 100644 --- a/crates/lash-cli/src/model_selection.rs +++ b/crates/lash-cli/src/model_selection.rs @@ -51,10 +51,10 @@ fn parse_variant_input(input: &str) -> Result { } pub(crate) fn validate_model_selection( - provider: &ProviderHandle, - selection: &ModelSelection, + _provider: &ProviderHandle, + _selection: &ModelSelection, ) -> Result<(), String> { - provider.validate_model_name(&selection.model) + Ok(()) } /// Curated model limits that take precedence over models.dev — only for @@ -84,7 +84,6 @@ pub(crate) fn resolve_model_selection( selection: &ModelSelection, catalog: &CachedModelCatalog, ) -> Result { - provider.validate_model_name(&selection.model)?; let configured_model = selection.model.trim(); let catalog_model_id = crate::provider_metadata::provider_catalog_model_id(provider.kind(), configured_model); diff --git a/crates/lash-cli/src/overlay.rs b/crates/lash-cli/src/overlay.rs index 3de8124..07db7ed 100644 --- a/crates/lash-cli/src/overlay.rs +++ b/crates/lash-cli/src/overlay.rs @@ -1,6 +1,7 @@ use std::collections::BTreeSet; -use lash_core::{Message, MessageRole, PartKind, SessionMessageTreeNode}; +use crate::tree::SessionMessageTreeNode; +use lash_core::{Message, MessageRole, PartKind}; use crate::command; use crate::config::ThemeName; @@ -294,6 +295,7 @@ impl PromptState { #[derive(Clone, Debug, PartialEq, Eq)] pub enum CommandPaletteAction { Builtin(command::Command), + NewSession(crate::execution_settings::RlmDialect), InsertDraft(String), Theme(ThemeName), } @@ -489,10 +491,14 @@ pub struct SessionPickerState { pub selected: usize, pub query: String, pub showing_empty_sessions: bool, + pub incompatible_session_count: usize, } impl SessionPickerState { - pub fn new(items: Vec) -> Self { + pub fn with_incompatible_sessions( + items: Vec, + incompatible_session_count: usize, + ) -> Self { let has_non_empty = items.iter().any(|session| session.message_count > 0); let items = if has_non_empty { items @@ -509,6 +515,7 @@ impl SessionPickerState { selected: 0, query: String::new(), showing_empty_sessions, + incompatible_session_count, } } @@ -766,7 +773,7 @@ fn is_user_visible_message(message: &Message) -> bool { message.parts.iter().any(|part| { matches!( part.kind, - PartKind::Text | PartKind::Prose | PartKind::Image + PartKind::Text | PartKind::Prose | PartKind::Attachment ) }) } @@ -837,11 +844,11 @@ pub fn tree_message_preview(message: &Message) -> String { preview.push_str(part.content.trim()); } } - PartKind::Image => { + PartKind::Attachment => { if !preview.is_empty() { preview.push(' '); } - preview.push_str("[image]"); + preview.push_str("[attachment]"); } PartKind::ToolCall => { if !preview.is_empty() { diff --git a/crates/lash-cli/src/paths.rs b/crates/lash-cli/src/paths.rs index c800bdc..9c19911 100644 --- a/crates/lash-cli/src/paths.rs +++ b/crates/lash-cli/src/paths.rs @@ -50,8 +50,42 @@ pub fn config_file() -> PathBuf { lash_home().join("config.json") } +/// Root of Lash-owned durable stores for this CLI installation. +pub fn store_dir() -> PathBuf { + lash_home().join("store") +} + +pub fn durable_core_db() -> PathBuf { + store_dir().join("durable-core.db") +} + +pub fn processes_db() -> PathBuf { + store_dir().join("processes.db") +} + +pub fn triggers_db() -> PathBuf { + store_dir().join("triggers.db") +} + +pub fn effects_db() -> PathBuf { + store_dir().join("effects.db") +} + +pub fn artifacts_db() -> PathBuf { + store_dir().join("artifacts.db") +} + +pub fn process_env_db() -> PathBuf { + store_dir().join("process-env.db") +} + pub fn attachments_dir() -> PathBuf { - lash_home().join("attachments") + store_dir().join("attachments") +} + +/// Host-owned roster and UI sidecars keyed by Lash session id. +pub fn sessions_dir() -> PathBuf { + lash_home().join("sessions") } /// Stable id for this CLI installation on this host, stored under `$LASH_HOME`. diff --git a/crates/lash-cli/src/persistence.rs b/crates/lash-cli/src/persistence.rs deleted file mode 100644 index 3b81d9f..0000000 --- a/crates/lash-cli/src/persistence.rs +++ /dev/null @@ -1,20 +0,0 @@ -use lash_core::runtime::RuntimeSessionState; -use lash_core::store::RuntimeCommit; -use lash_sqlite_store::Store; - -pub(crate) async fn persist_committed_runtime_state( - store: &Store, - state: &mut RuntimeSessionState, -) { - match lash_core::SessionCommitStore::commit_runtime_state( - store, - RuntimeCommit::persisted_state(state, &[]), - ) - .await - { - Ok(result) => { - state.apply_persisted_commit_result(result); - } - Err(err) => tracing::warn!("failed to persist committed runtime state: {err}"), - } -} diff --git a/crates/lash-cli/src/plan_plugin.rs b/crates/lash-cli/src/plan_plugin.rs new file mode 100644 index 0000000..c936e09 --- /dev/null +++ b/crates/lash-cli/src/plan_plugin.rs @@ -0,0 +1,138 @@ +use std::sync::Arc; + +use lash::plugins::{ + PluginDirective, PluginError, PluginFactory, PluginRegistrar, PluginRuntimeEvent, + PluginSessionContext, SessionPlugin, +}; +use lash::tools::{ + ToolCall, ToolContract, ToolDefinition, ToolManifest, ToolOutcome, ToolProvider, +}; +use lash_lashlang_runtime::{ToolBinding, ToolDefinitionBindingExt}; +use serde::{Deserialize, Serialize}; + +pub(crate) const PLUGIN_ID: &str = "update_plan"; +pub(crate) const PLAN_EVENT: &str = "plan_mode.state"; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct PlanItem { + pub(crate) step: String, + pub(crate) status: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct PlanSnapshot { + #[serde(default)] + pub(crate) explanation: Option, + #[serde(default)] + pub(crate) generation: u64, + pub(crate) plan: Vec, +} + +pub(crate) struct UpdatePlanPluginFactory; + +impl PluginFactory for UpdatePlanPluginFactory { + fn id(&self) -> &'static str { + PLUGIN_ID + } + + fn build(&self, context: &PluginSessionContext) -> Result, PluginError> { + Ok(Arc::new(UpdatePlanPlugin { + active: context.is_root_session(), + })) + } +} + +struct UpdatePlanPlugin { + active: bool, +} + +impl SessionPlugin for UpdatePlanPlugin { + fn id(&self) -> &'static str { + PLUGIN_ID + } + + fn register(&self, registrar: &mut PluginRegistrar) -> Result<(), PluginError> { + if !self.active { + return Ok(()); + } + registrar + .tools() + .provider(Arc::new(UpdatePlanTool) as Arc)?; + registrar.tool_calls().after(Arc::new(|context| { + Box::pin(async move { + if context.tool_name != "update_plan" { + return Ok(Vec::new()); + } + let snapshot = serde_json::from_value::(context.args) + .map_err(|error| PluginError::Invoke(error.to_string()))?; + Ok(vec![PluginDirective::emit_runtime_events(vec![ + PluginRuntimeEvent::Custom { + name: PLAN_EVENT.to_string(), + payload: serde_json::to_value(snapshot) + .map_err(|error| PluginError::Invoke(error.to_string()))?, + }, + ])]) + }) + })); + Ok(()) + } +} + +struct UpdatePlanTool; + +#[async_trait::async_trait] +impl ToolProvider for UpdatePlanTool { + fn tool_manifests(&self) -> Vec { + vec![update_plan_definition().manifest()] + } + + fn resolve_contract(&self, name: &str) -> Option> { + (name == "update_plan").then(|| Arc::new(update_plan_definition().contract())) + } + + async fn execute(&self, call: ToolCall<'_>) -> ToolOutcome { + match serde_json::from_value::(call.args.clone()) { + Ok(snapshot) => ToolOutcome::ok(serde_json::json!({ + "generation": snapshot.generation, + })), + Err(error) => ToolOutcome::err_fmt(format_args!("invalid plan: {error}")), + } + } +} + +fn update_plan_definition() -> ToolDefinition { + ToolDefinition::raw( + "tool:update_plan", + "update_plan", + "Publish or replace the current task plan.", + serde_json::json!({ + "type": "object", + "additionalProperties": false, + "required": ["plan"], + "properties": { + "explanation": {"type": ["string", "null"]}, + "generation": {"type": "integer", "minimum": 0}, + "plan": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["step", "status"], + "properties": { + "step": {"type": "string"}, + "status": {"enum": ["pending", "in_progress", "completed"]} + } + } + } + } + }), + serde_json::json!({ + "type": "object", + "required": ["generation"], + "properties": {"generation": {"type": "integer"}} + }), + ) + .with_tool_binding(ToolBinding::new(["plan"], "update")) +} diff --git a/crates/lash-cli/src/plugin_surface.rs b/crates/lash-cli/src/plugin_surface.rs index 3c15f62..95a7f33 100644 --- a/crates/lash-cli/src/plugin_surface.rs +++ b/crates/lash-cli/src/plugin_surface.rs @@ -2,9 +2,7 @@ use std::collections::BTreeMap; use lash_core::PluginRuntimeEvent; -use crate::app::{ - PlanDockItem, PlanDockItemStatus, PlanDockState, PluginPanelBlock, UiTimeline, UiTimelineItem, -}; +use crate::app::{PlanDockItem, PlanDockItemStatus, PlanDockState, UiTimeline}; pub struct PluginRuntimeMutation { pub blocks_changed: bool, @@ -39,12 +37,12 @@ pub fn surface_key(plugin_id: &str, key: &str) -> String { pub(crate) fn event_renders_visible_output(event: &PluginRuntimeEvent) -> bool { match event { - PluginRuntimeEvent::Custom { name, .. } => name == "plan_mode.state", + PluginRuntimeEvent::Custom { name, .. } => name == crate::plan_plugin::PLAN_EVENT, PluginRuntimeEvent::Status { .. } => false, } } -fn plan_state_from_snapshot(snapshot: lash_plugin_plan_mode::PlanSnapshot) -> PlanDockState { +fn plan_state_from_snapshot(snapshot: crate::plan_plugin::PlanSnapshot) -> PlanDockState { PlanDockState { title: "PLAN".to_string(), meta: snapshot.explanation, @@ -63,93 +61,9 @@ fn plan_state_from_snapshot(snapshot: lash_plugin_plan_mode::PlanSnapshot) -> Pl } } -fn upsert_panel( - blocks: &mut UiTimeline, - plugin_id: &str, - key: &str, - title: String, - content: String, -) -> bool { - let target_key = surface_key(plugin_id, key); - if let Some(existing) = blocks.iter_mut().find_map(|block| match block { - UiTimelineItem::PluginPanel(panel) - if surface_key(&panel.plugin_id, &panel.key) == target_key => - { - Some(panel) - } - _ => None, - }) { - let changed = existing.title != title || existing.content != content; - existing.title = title; - existing.content = content; - changed - } else { - blocks.push(UiTimelineItem::PluginPanel(PluginPanelBlock { - plugin_id: plugin_id.to_string(), - key: key.to_string(), - title, - content, - })); - true - } -} - -fn clear_panel(blocks: &mut UiTimeline, plugin_id: &str, key: &str) -> bool { - let original_len = blocks.len(); - let target_key = surface_key(plugin_id, key); - blocks.retain(|block| match block { - UiTimelineItem::PluginPanel(panel) => { - surface_key(&panel.plugin_id, &panel.key) != target_key - } - _ => true, - }); - blocks.len() != original_len -} - -fn apply_plan_protocol_state( - blocks: &mut UiTimeline, - indicators: &mut BTreeMap, - payload: serde_json::Value, -) -> PluginRuntimeMutation { - let Ok(status) = - serde_json::from_value::(payload) - else { - return PluginRuntimeMutation::blocks_changed(false); - }; - let indicator_key = surface_key("plan_mode", "mode"); - let indicators_changed = if status.enabled { - let label = "plan".to_string(); - let changed = indicators.get(&indicator_key) != Some(&label); - indicators.insert(indicator_key, label); - changed - } else { - indicators.remove(&indicator_key).is_some() - }; - let blocks_changed = if status.enabled { - if let Some(path) = status.plan_path { - upsert_panel( - blocks, - "plan_mode", - "panel", - "PLAN".to_string(), - format!("Path: `{path}`"), - ) - } else { - clear_panel(blocks, "plan_mode", "panel") - } - } else { - clear_panel(blocks, "plan_mode", "panel") - }; - PluginRuntimeMutation { - blocks_changed, - indicators_changed, - plan_dock_change: None, - } -} - pub fn apply_surface_event( - blocks: &mut UiTimeline, - indicators: &mut BTreeMap, + _blocks: &mut UiTimeline, + _indicators: &mut BTreeMap, _plan_dock: &Option, plugin_id: &str, event: PluginRuntimeEvent, @@ -157,9 +71,10 @@ pub fn apply_surface_event( match event { PluginRuntimeEvent::Status { .. } => PluginRuntimeMutation::blocks_changed(false), PluginRuntimeEvent::Custom { name, payload } => { - if plugin_id == "update_plan" && name == "update_plan.snapshot" { + if plugin_id == crate::plan_plugin::PLUGIN_ID && name == crate::plan_plugin::PLAN_EVENT + { let Ok(snapshot) = - serde_json::from_value::(payload) + serde_json::from_value::(payload) else { return PluginRuntimeMutation::blocks_changed(false); }; @@ -174,9 +89,6 @@ pub fn apply_surface_event( } return PluginRuntimeMutation::plan_dock_update(Some(next)); } - if plugin_id == "plan_mode" && name == "plan_mode.state" { - return apply_plan_protocol_state(blocks, indicators, payload); - } PluginRuntimeMutation::blocks_changed(false) } } @@ -185,7 +97,7 @@ pub fn apply_surface_event( #[cfg(test)] mod tests { use super::*; - use lash_plugin_plan_mode::{PlanItem, PlanModeExternalStatus, PlanSnapshot}; + use crate::plan_plugin::{PlanItem, PlanSnapshot}; fn custom(name: &str, payload: impl serde::Serialize) -> PluginRuntimeEvent { PluginRuntimeEvent::Custom { @@ -206,7 +118,7 @@ mod tests { &dock, "update_plan", custom( - "update_plan.snapshot", + crate::plan_plugin::PLAN_EVENT, PlanSnapshot { explanation: None, generation: 1, @@ -260,7 +172,7 @@ mod tests { &dock, "update_plan", custom( - "update_plan.snapshot", + crate::plan_plugin::PLAN_EVENT, PlanSnapshot { explanation: None, generation: 2, @@ -289,41 +201,6 @@ mod tests { ); } - #[test] - fn plan_protocol_state_updates_indicator_and_panel_without_touching_dock() { - let mut blocks = UiTimeline::default(); - let mut indicators = BTreeMap::new(); - let dock = Some(PlanDockState { - title: "PLAN".into(), - meta: None, - items: vec![PlanDockItem { - text: "Patch layout".into(), - status: PlanDockItemStatus::Active, - }], - }); - - let mutation = apply_surface_event( - &mut blocks, - &mut indicators, - &dock, - "plan_mode", - custom( - "plan_mode.state", - PlanModeExternalStatus { - session_id: "root".into(), - enabled: true, - plan_path: Some(".lash/plans/root.md".into()), - }, - ), - ); - - assert!(mutation.blocks_changed); - assert!(mutation.indicators_changed); - assert!(mutation.plan_dock_change.is_none()); - assert_eq!(indicators.get("plan_mode:mode"), Some(&"plan".to_string())); - assert_eq!(blocks.len(), 1); - } - #[test] fn unrelated_custom_event_is_ignored() { let mut blocks = UiTimeline::default(); diff --git a/crates/lash-cli/src/prompt_context_plugin.rs b/crates/lash-cli/src/prompt_context_plugin.rs index f1066ef..ed18c6a 100644 --- a/crates/lash-cli/src/prompt_context_plugin.rs +++ b/crates/lash-cli/src/prompt_context_plugin.rs @@ -2,13 +2,12 @@ use std::sync::Arc; use chrono::Utc; -use lash_core::PreparedContext; -use lash_core::PromptContribution; -use lash_core::plugin::{ +use lash::plugins::{ ContextError, PluginDirective, PluginError, PluginFactory, PluginRegistrar, - PluginSessionContext, TurnContextTransform, TurnTransformContext, + PluginSessionContext, PreparedContext, SessionPlugin, TurnContextTransform, + TurnTransformContext, }; -use lash_core::{Message, MessageRole, Part, PartKind, PluginMessage, PruneState, shared_parts}; +use lash_core::{Message, MessageRole, Part, PluginMessage, PromptContribution}; use crate::execution_settings::ExecutionMode; @@ -62,10 +61,7 @@ impl PluginFactory for PromptContextPluginFactory { "prompt_context" } - fn build( - &self, - _ctx: &PluginSessionContext, - ) -> Result, PluginError> { + fn build(&self, _ctx: &PluginSessionContext) -> Result, PluginError> { Ok(Arc::new(PromptContextPlugin { instruction_source: Arc::clone(&self.instruction_source), config: self.config.clone(), @@ -80,7 +76,7 @@ struct PromptContextPlugin { execution_mode: ExecutionMode, } -impl lash_core::SessionPlugin for PromptContextPlugin { +impl SessionPlugin for PromptContextPlugin { fn id(&self) -> &'static str { "prompt_context" } @@ -153,15 +149,11 @@ impl TurnContextTransform for EnvironmentTailTransform { return Ok(input); } - let mut messages: Vec = input.messages.as_slice().to_vec(); + let mut output = input; + let mut messages: Vec = output.messages.iter().cloned().collect(); messages.push(environment_tail_message(context)); - - let base = Arc::new(messages); - let cache = Arc::new(lash_core::BaseRenderCache::new()); - Ok(PreparedContext { - messages: lash_core::MessageSequence::from_base(base).with_base_render_cache(cache), - ..input - }) + output.messages = messages.into(); + Ok(output) } } @@ -170,18 +162,12 @@ fn environment_tail_message(content: String) -> Message { Message { id: id.to_string(), role: MessageRole::User, - parts: shared_parts(vec![Part { - id: format!("{id}.p0"), - kind: PartKind::Prose, - content: format!("\n{content}\n"), - attachment: None, - tool_call_id: None, - tool_name: None, - tool_replay: None, - prune_state: PruneState::Intact, - reasoning_meta: None, - response_meta: None, - }]), + parts: vec![Part::prose( + format!("{id}.p0"), + format!("\n{content}\n"), + None, + )] + .into(), origin: Some(lash_core::MessageOrigin::Plugin { plugin_id: "prompt_context".to_string(), transient: true, @@ -204,80 +190,3 @@ fn build_prompt_environment_context() -> String { parts.join("\n") } - -#[cfg(test)] -mod tests { - use super::*; - use lash_core::plugin::PromptHookContext; - use lash_core::testing::{MockSessionManager, mock_assembled_turn}; - use lash_core::{PluginHost, PromptSlot, SessionPolicy, SessionReadView, SessionSnapshot}; - - struct StaticInstructionSource { - text: String, - read_text: String, - } - - impl InstructionSource for StaticInstructionSource { - fn system_instructions(&self) -> String { - self.text.clone() - } - - fn context_instructions_for_reads(&self, _read_paths: &[String]) -> String { - self.read_text.clone() - } - } - - fn mock_snapshot(run_session_id: &str) -> SessionSnapshot { - SessionSnapshot { - session_id: "root".to_string(), - policy: SessionPolicy { - session_id: Some(run_session_id.to_string()), - ..Default::default() - }, - ..Default::default() - } - } - - fn mock_session_manager(run_session_id: &str) -> MockSessionManager { - MockSessionManager::default() - .with_snapshot(mock_snapshot(run_session_id)) - .with_turn(mock_assembled_turn(run_session_id, "")) - } - - #[tokio::test] - async fn prompt_context_plugin_contributes_environment_and_project_instruction_sections() { - let mut factories = lash_core::testing::test_standard_protocol_factories(); - factories.push(Arc::new(PromptContextPluginFactory::new( - Arc::new(StaticInstructionSource { - text: "Repo rules".to_string(), - read_text: String::new(), - }), - PromptContextPluginConfig::default(), - ExecutionMode::Standard, - ))); - let host = PluginHost::new(factories); - let session = host.build_session("root", None).expect("session"); - let contributions = session - .collect_prompt_contributions(PromptHookContext { - session_id: "root".to_string(), - sessions: Arc::new(mock_session_manager("run-session")), - state: SessionReadView::from_snapshot(&SessionSnapshot::default()), - protocol_turn_options: lash_core::ProtocolTurnOptions::default(), - turn_context: lash_core::TurnContext::default(), - }) - .await - .expect("prompt contributions"); - - assert!( - !contributions - .iter() - .any(|contribution| contribution.slot == PromptSlot::RuntimeContext), - "environment context should not appear as a runtime-context contribution", - ); - assert!(contributions.iter().any(|contribution| { - contribution.slot == PromptSlot::ProjectInstructions - && contribution.title.as_deref() == Some("Project Instructions") - && contribution.content.as_ref() == "Repo rules" - })); - } -} diff --git a/crates/lash-cli/src/prompt_model.rs b/crates/lash-cli/src/prompt_model.rs index 736e221..ae4583f 100644 --- a/crates/lash-cli/src/prompt_model.rs +++ b/crates/lash-cli/src/prompt_model.rs @@ -53,6 +53,7 @@ impl PromptRequest { self } + #[cfg(test)] pub(crate) fn with_markdown_panel( mut self, title: impl Into, diff --git a/crates/lash-cli/src/prompt_tool.rs b/crates/lash-cli/src/prompt_tool.rs index 3123edc..2458225 100644 --- a/crates/lash-cli/src/prompt_tool.rs +++ b/crates/lash-cli/src/prompt_tool.rs @@ -1,10 +1,10 @@ use std::sync::{Arc, Mutex}; -use lash::tools::{LashlangToolBinding, ToolDefinitionLashlangExt}; +use lash::plugins::{PluginFactory, PluginSpec}; +use lash::tools::{ToolBinding, ToolDefinitionBindingExt}; use lash_core::plugin::StaticPluginFactory; use lash_core::{ - PluginError, PluginFactory, PluginSpec, ToolCall, ToolContract, ToolDefinition, ToolManifest, - ToolProvider, ToolResult, ToolScheduling, + PluginError, ToolCall, ToolContract, ToolDefinition, ToolManifest, ToolOutcome, ToolProvider, }; use serde_json::json; @@ -44,29 +44,6 @@ impl CliPromptBridge { } } -#[async_trait::async_trait] -impl lash_plugin_plan_mode::PlanModePrompt for CliPromptBridge { - async fn prompt_user( - &self, - request: lash_plugin_plan_mode::PlanModePromptRequest, - ) -> Result { - let mut cli_request = PromptRequest::single(request.question, request.options); - if let Some(review) = request.review { - cli_request = cli_request.with_markdown_panel(review.title, review.markdown); - } - if request.allow_note { - cli_request = cli_request.with_optional_note(); - } - let response = self.prompt_user(cli_request).await?; - let (selection, note) = match response { - PromptResponse::Single { selection, note } => (selection, note), - PromptResponse::Multi { selections, note } => (selections.join(", "), note), - PromptResponse::Text { text } => (text, None), - }; - Ok(lash_plugin_plan_mode::PlanModePromptResponse::Single { selection, note }) - } -} - #[derive(Clone)] struct CliAskTool { prompt: CliPromptBridge, @@ -77,7 +54,7 @@ impl CliAskTool { Self { prompt } } - async fn execute_ask(&self, args: &serde_json::Value) -> ToolResult { + async fn execute_ask(&self, args: &serde_json::Value) -> ToolOutcome { let question = match require_str(args, "question") { Ok(question) => question, Err(err) => return err, @@ -109,8 +86,8 @@ impl CliAskTool { }; match self.prompt.prompt_user(request).await { - Ok(answer) => ToolResult::ok(json!(answer)), - Err(err) => ToolResult::err(json!(err.to_string())), + Ok(answer) => ToolOutcome::ok(json!(answer)), + Err(err) => ToolOutcome::err(json!(err.to_string())), } } } @@ -125,10 +102,10 @@ impl ToolProvider for CliAskTool { (name == "ask").then(|| Arc::new(ask_tool_definition().contract())) } - async fn execute(&self, call: ToolCall<'_>) -> ToolResult { + async fn execute(&self, call: ToolCall<'_>) -> ToolOutcome { match call.name { "ask" => self.execute_ask(call.args).await, - other => ToolResult::err_fmt(format_args!("Unknown tool: {other}")), + other => ToolOutcome::err_fmt(format_args!("Unknown tool: {other}")), } } } @@ -165,11 +142,10 @@ fn ask_tool_definition() -> ToolDefinition { .into(), "await user.ask({ question: \"Which checks should I run?\", options: [\"unit\", \"lint\", \"e2e\"], selection_mode: \"multi\" })?".into(), ]) - .with_lashlang_binding( - LashlangToolBinding::new(["user"], "ask") + .with_tool_binding( + ToolBinding::new(["user"], "ask") .with_aliases(["prompt_user", "request_input"]), ) - .with_scheduling(ToolScheduling::Parallel) } fn ask_output_schema() -> serde_json::Value { @@ -218,11 +194,11 @@ pub(crate) fn cli_ask_plugin_factory(prompt: CliPromptBridge) -> Arc(args: &'a serde_json::Value, name: &str) -> Result<&'a str, ToolResult> { +fn require_str<'a>(args: &'a serde_json::Value, name: &str) -> Result<&'a str, ToolOutcome> { args.get(name) .and_then(serde_json::Value::as_str) .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| ToolResult::err_fmt(format!("Invalid {name}: expected non-empty string"))) + .ok_or_else(|| ToolOutcome::err_fmt(format!("Invalid {name}: expected non-empty string"))) } fn object_schema(properties: serde_json::Value, required: &[&str]) -> serde_json::Value { @@ -234,7 +210,7 @@ fn object_schema(properties: serde_json::Value, required: &[&str]) -> serde_json }) } -fn parse_options(args: &serde_json::Value) -> Result, ToolResult> { +fn parse_options(args: &serde_json::Value) -> Result, ToolOutcome> { let Some(value) = args.get("options") else { return Ok(Vec::new()); }; @@ -242,19 +218,19 @@ fn parse_options(args: &serde_json::Value) -> Result, ToolResult> { return Ok(Vec::new()); } let Some(items) = value.as_array() else { - return Err(ToolResult::err_fmt( + return Err(ToolOutcome::err_fmt( "Invalid options: expected list of strings", )); }; let mut out = Vec::with_capacity(items.len()); for (idx, item) in items.iter().enumerate() { let Some(option) = item.as_str() else { - return Err(ToolResult::err_fmt(format!( + return Err(ToolOutcome::err_fmt(format!( "Invalid options[{idx}]: expected non-empty string" ))); }; if option.trim().is_empty() { - return Err(ToolResult::err_fmt(format!( + return Err(ToolOutcome::err_fmt(format!( "Invalid options[{idx}]: expected non-empty string" ))); } @@ -266,12 +242,12 @@ fn parse_options(args: &serde_json::Value) -> Result, ToolResult> { fn parse_selection_mode( args: &serde_json::Value, has_options: bool, -) -> Result { +) -> Result { let Some(value) = args.get("selection_mode") else { return Ok(PromptSelectionMode::Single); }; let Some(mode) = value.as_str() else { - return Err(ToolResult::err_fmt( + return Err(ToolOutcome::err_fmt( "Invalid selection_mode: expected \"single\" or \"multi\"", )); }; @@ -279,20 +255,20 @@ fn parse_selection_mode( "single" => PromptSelectionMode::Single, "multi" => PromptSelectionMode::Multi, _ => { - return Err(ToolResult::err_fmt( + return Err(ToolOutcome::err_fmt( "Invalid selection_mode: expected \"single\" or \"multi\"", )); } }; if !has_options && matches!(selection_mode, PromptSelectionMode::Multi) { - return Err(ToolResult::err_fmt( + return Err(ToolOutcome::err_fmt( "Invalid selection_mode: \"multi\" requires non-empty options", )); } Ok(selection_mode) } -fn parse_allow_note(args: &serde_json::Value, has_options: bool) -> Result { +fn parse_allow_note(args: &serde_json::Value, has_options: bool) -> Result { let Some(value) = args.get("allow_note") else { return Ok(false); }; @@ -300,12 +276,12 @@ fn parse_allow_note(args: &serde_json::Value, has_options: bool) -> Result, app: &App, history_area: Rect) { } let filtered_indices = picker.filtered_indices(); let match_count = filtered_indices.len(); - let max_list_height = history_area.height.saturating_sub(4).max(1) as usize; + let notice_rows = u16::from(picker.incompatible_session_count > 0); + let max_list_height = history_area + .height + .saturating_sub(4 + notice_rows) + .max(1) as usize; let list_height = match_count.clamp(1, 15).min(max_list_height) as u16; - let height = list_height + 4; // title + search + list + footer + let height = list_height + 4 + notice_rows; // title + search + notice + list + footer draw_overlay_scrim(frame, history_area); let popup = centered_rect(history_area, width, height); frame.draw_box( @@ -233,10 +237,24 @@ fn draw_session_picker(frame: &mut Frame<'_>, app: &App, history_area: Rect) { popup.width.saturating_sub(4), ); - if picker.items.is_empty() { + if picker.incompatible_session_count > 0 { frame.write_text( popup.x + 2, popup.y + 2, + &format!( + "{} sessions from an older Lash are not openable", + picker.incompatible_session_count + ), + theme::text_faint_style(), + popup.width.saturating_sub(4), + ); + } + let list_y = popup.y + 2 + notice_rows; + + if picker.items.is_empty() { + frame.write_text( + popup.x + 2, + list_y, "No sessions yet", theme::text_faint_style(), popup.width.saturating_sub(4), @@ -244,7 +262,7 @@ fn draw_session_picker(frame: &mut Frame<'_>, app: &App, history_area: Rect) { } else if match_count == 0 { frame.write_text( popup.x + 2, - popup.y + 2, + list_y, "No matching sessions", theme::text_faint_style(), popup.width.saturating_sub(4), @@ -305,7 +323,7 @@ fn draw_session_picker(frame: &mut Frame<'_>, app: &App, history_area: Rect) { }; frame.write_text( popup.x + 1, - popup.y + 2 + row as u16, + list_y + row as u16, &line, style, popup.width.saturating_sub(2), diff --git a/crates/lash-cli/src/render/compositor/status.rs b/crates/lash-cli/src/render/compositor/status.rs index e0c3802..c310711 100644 --- a/crates/lash-cli/src/render/compositor/status.rs +++ b/crates/lash-cli/src/render/compositor/status.rs @@ -171,6 +171,15 @@ fn build_status_slots(app: &App) -> (Vec, Vec) { priority: 80, }); } + if let Some(dialect) = app.rlm_dialect_label.as_deref() { + left.push(StatusSlot { + spans: vec![ + Span::styled(" · ", sep_style), + Span::styled(dialect.to_string(), theme::text_subtle_style()), + ], + priority: 79, + }); + } for label in app.plugin_mode_indicators.values() { left.push(StatusSlot { spans: vec![ diff --git a/crates/lash-cli/src/render/compositor/tests.rs b/crates/lash-cli/src/render/compositor/tests.rs index ef02dae..402bf85 100644 --- a/crates/lash-cli/src/render/compositor/tests.rs +++ b/crates/lash-cli/src/render/compositor/tests.rs @@ -381,10 +381,10 @@ mod tests { Arc::new(TuiExtensions::new(vec![chrome_ext]).expect("chrome extension")); app.set_ui_extensions(ui_extensions); app.set_chrome_state(chrome_state); - app.update_processes(vec![lash_core::ProcessHandleSummary::new( + app.update_processes(vec![lash_core::ProcessHandleView::new( "process-1", - lash_core::ProcessHandleDescriptor::new(Some("lashlang"), Some("responder")), - lash_core::ProcessLifecycleStatus::Running, + lash_core::ProcessIdentity::new("lashlang").with_label(Some("responder")), + lash_core::ProcessStatus::Running, )]); sync_chrome_turn_status(&app); @@ -422,12 +422,11 @@ mod tests { fn process_dock_renders_below_input_and_overview_as_overlay() { let mut app = App::new("gpt-5.4".into(), "test".into(), "test-session-id".into()); app.update_processes(vec![ - lash_core::ProcessHandleSummary::new( + lash_core::ProcessHandleView::new( "process-1", - lash_core::ProcessHandleDescriptor::new(Some("lashlang"), Some("responder")), - lash_core::ProcessLifecycleStatus::Running, - ) - .with_definition(Some( + lash_core::ProcessIdentity::new("lashlang") + .with_label(Some("responder")) + .with_definition(Some( serde_json::to_value(lashlang::ProcessDefinitionIdentity::new( lashlang::ModuleRef::new(&lashlang::ContentHash::new("module")), lashlang::HostRequirementsRef::new(&lashlang::ContentHash::new("host")), @@ -435,7 +434,9 @@ mod tests { "responder", )) .expect("process definition serializes"), - )), + )), + lash_core::ProcessStatus::Running, + ), ]); app.select_next_process(); @@ -624,7 +625,7 @@ mod tests { fn handle_turn_event(&self, event: &lash_core::TurnEvent) -> Vec { match event { - lash_core::TurnEvent::AssistantProseDelta { text } if text == "mount" => vec![ + lash_core::TurnEvent::AssistantProseDelta { text } if text.as_ref() == "mount" => vec![ TuiHostEffect::MountSurface { spec: TuiSurfaceSpec { key: "workspace".to_string(), @@ -648,7 +649,7 @@ mod tests { }, }, ], - lash_core::TurnEvent::AssistantProseDelta { text } if text == "overlay" => vec![ + lash_core::TurnEvent::AssistantProseDelta { text } if text.as_ref() == "overlay" => vec![ TuiHostEffect::MountSurface { spec: TuiSurfaceSpec { key: "overlay".to_string(), @@ -681,7 +682,7 @@ mod tests { .expect("surface extensions"), ); ui_extensions.effects_for_turn_event(&lash_core::TurnEvent::AssistantProseDelta { - text: "mount".to_string(), + text: "mount".into(), }); app.set_ui_extensions(Arc::clone(&ui_extensions)); @@ -701,10 +702,10 @@ mod tests { .expect("surface extensions"), ); ui_extensions.effects_for_turn_event(&lash_core::TurnEvent::AssistantProseDelta { - text: "mount".to_string(), + text: "mount".into(), }); ui_extensions.effects_for_turn_event(&lash_core::TurnEvent::AssistantProseDelta { - text: "overlay".to_string(), + text: "overlay".into(), }); app.set_ui_extensions(Arc::clone(&ui_extensions)); diff --git a/crates/lash-cli/src/render/sections/docks.rs b/crates/lash-cli/src/render/sections/docks.rs index 4da6113..49d8648 100644 --- a/crates/lash-cli/src/render/sections/docks.rs +++ b/crates/lash-cli/src/render/sections/docks.rs @@ -75,12 +75,14 @@ pub fn process_lines_snapshot(app: &App, _frame_width: u16) -> Option "running", - Some(lash_core::ProcessTerminalState::Completed) => "success", - Some(lash_core::ProcessTerminalState::Failed) => "error", - Some(lash_core::ProcessTerminalState::Cancelled) => "cancelled", - Some(lash_core::ProcessTerminalState::Abandoned) => "abandoned", + let state = match task.status { + lash::process::ProcessStatus::Completed => "success", + lash::process::ProcessStatus::Failed => "error", + lash::process::ProcessStatus::Cancelled => "cancelled", + lash::process::ProcessStatus::Abandoned => "abandoned", + lash::process::ProcessStatus::Running + | lash::process::ProcessStatus::Waiting + | lash::process::ProcessStatus::CallerDeparted => "running", }; let producer = task.definition.as_deref().unwrap_or(task.kind.as_str()); let elapsed_duration = task @@ -89,12 +91,14 @@ pub fn process_lines_snapshot(app: &App, _frame_width: u16) -> Option theme::turn_status_state(), - Some(lash_core::ProcessTerminalState::Completed) => theme::tool_success(), - Some(lash_core::ProcessTerminalState::Failed) - | Some(lash_core::ProcessTerminalState::Cancelled) - | Some(lash_core::ProcessTerminalState::Abandoned) => theme::tool_failure(), + let state_style = match task.status { + lash::process::ProcessStatus::Completed => theme::tool_success(), + lash::process::ProcessStatus::Failed + | lash::process::ProcessStatus::Cancelled + | lash::process::ProcessStatus::Abandoned => theme::tool_failure(), + lash::process::ProcessStatus::Running + | lash::process::ProcessStatus::Waiting + | lash::process::ProcessStatus::CallerDeparted => theme::turn_status_state(), }; let selected_chrome = theme::process_selected_chrome(); let row_style = |style: Style| { diff --git a/crates/lash-cli/src/render/tests.rs b/crates/lash-cli/src/render/tests.rs index 36b3ed8..7dacd51 100644 --- a/crates/lash-cli/src/render/tests.rs +++ b/crates/lash-cli/src/render/tests.rs @@ -8,7 +8,7 @@ use crate::assistant_text::normalize_assistant_text; use crate::prompt_model::PromptRequest; use crate::theme; use async_trait::async_trait; -use lash_core::{Part, PartKind}; +use lash_core::Part; use lash_tui_extensions::{ SlashCommandSpec, TuiExtension, TuiExtensionContext, TuiExtensions, TuiHostEffect, }; @@ -47,10 +47,10 @@ fn timeline_items_from_test_read_view( .cloned() .collect::>(); graph.append_active_read_delta(&missing_messages); - let state = lash_core::SessionSnapshot { - session_graph: graph, - ..lash_core::SessionSnapshot::default() - }; + let mut state = lash_core::SessionSnapshot::new(lash_core::SessionPolicy::new( + crate::host_policy::turn_budget(), + )); + state.session_graph = graph; let read_view = lash_core::SessionReadView::from_snapshot(&state); timeline_from_read_view(&read_view, ui_state) .items() @@ -356,18 +356,11 @@ fn interrupted_projection_hides_appended_skill_blocks_in_user_text() { let message = lash_core::Message { id: "m1".into(), role: lash_core::MessageRole::User, - parts: vec![Part { - id: "m1.p1".into(), - kind: PartKind::Text, - content: "Use /wholehog\n\n\nwholehog\nbody\n".into(), - attachment: None, - tool_call_id: None, - tool_name: None, - tool_replay: None, - prune_state: lash_core::PruneState::Intact, - reasoning_meta: None, - response_meta: None, - }] + parts: vec![Part::text( + "m1.p1".into(), + "Use /wholehog\n\n\nwholehog\nbody\n".into(), + None, + )] .into(), origin: None, }; @@ -1408,15 +1401,19 @@ fn live_reasoning_compacts_after_activity_appends_below_it() { "live reasoning should expand while it is the tail; got {live_text:?}", ); - app.handle_session_event(lash_core::SessionStreamEvent::ToolCall { - call_id: None, - name: "read_file".into(), - args: serde_json::json!({ "path": "crates/lash/src/provider.rs" }), - output: lash_core::ToolCallOutput::success( - serde_json::json!({ "content": "provider code" }), - ), - duration_ms: 0, - }); + app.handle_turn_activity(lash::TurnActivity::independent( + lash::TurnEvent::ToolCallCompleted { + call_id: None, + name: "read_file".into(), + args: serde_json::json!({ "path": "crates/lash/src/provider.rs" }), + output: lash_core::ToolCallOutput::success( + serde_json::json!({ "content": "provider code" }), + ), + duration_ms: 0, + graph_key: None, + parent_call_id: None, + }, + )); let compact_text: Vec = app .rendered_block_lines_cached(0, 80, 24) @@ -1452,9 +1449,11 @@ fn committed_reasoning_compacts_while_live_assistant_streams() { )] .into(); - app.handle_session_event(lash_core::SessionStreamEvent::TextDelta { - content: "Answer is streaming.".into(), - }); + app.handle_turn_activity(lash::TurnActivity::independent( + lash::TurnEvent::AssistantProseDelta { + text: "Answer is streaming.".into(), + }, + )); let compact_text: Vec = app .rendered_block_lines_cached(0, 80, 24) diff --git a/crates/lash-cli/src/resume.rs b/crates/lash-cli/src/resume.rs index 89b3ccf..ca85281 100644 --- a/crates/lash-cli/src/resume.rs +++ b/crates/lash-cli/src/resume.rs @@ -1,251 +1,18 @@ -use std::sync::Arc; - use lash::LashSession; -use lash::SessionConfigPatch; -use lash_core::session_model::{ - Message, MessageRole, Part, PartKind, PruneState, fresh_message_id, -}; -use lash_core::store::HydratedSessionCheckpoint; -use lash_core::{ - PersistedSessionConfig, PersistedTurnState, PromptUsage, ProviderHandle, TokenUsage, ToolState, -}; -use lash_sqlite_store::Store; +use lash::provider::ProviderHandle; +use lash_core::ToolState; +use lash_core::session_model::Message; use crate::app::{App, UiTimelineItem}; use crate::execution_settings::ExecutionMode; use crate::model_catalog::CachedModelCatalog; use crate::session_log; -fn push_history_system_message(history: &mut Vec, content: String) { - let sys_id = fresh_message_id(); - history.push(Message { - id: sys_id.clone(), - role: MessageRole::System, - parts: lash_core::shared_parts(vec![Part { - id: format!("{}.p0", sys_id), - kind: PartKind::Text, - content, - attachment: None, - tool_call_id: None, - tool_name: None, - tool_replay: None, - prune_state: PruneState::Intact, - reasoning_meta: None, - response_meta: None, - }]), - origin: None, - }); -} - -fn execution_state_reset_message() -> String { - "Session resumed, but execution state could not be restored. Recreate any in-memory state you still need.".to_string() -} - -async fn restore_execution_state_if_present<'a>( - runtime: &'a mut Option, - app: &'a mut App, - history: &'a mut Vec, - snapshot: Option<&'a [u8]>, -) { - let Some(rt) = runtime.as_mut() else { - return; - }; - match snapshot { - Some(snapshot) => match rt.admin().state().restore_execution(snapshot).await { - Ok(()) => { - app.timeline.push(UiTimelineItem::SystemMessage( - "Execution state restored from snapshot.".to_string(), - )); - } - Err(e) => { - push_history_system_message( - history, - format!( - "Session resumed, but execution-state restore failed ({}). Recreate any in-memory state you still need.", - e - ), - ); - } - }, - None => { - push_history_system_message(history, execution_state_reset_message()); - } - } -} - -fn restored_token_usage(turn_state: Option<&PersistedTurnState>) -> Option { - let usage = turn_state?.token_usage.clone(); - (usage.total() > 0).then_some(usage) -} - -fn normalized_last_prompt_usage(last_prompt_usage: Option) -> Option { - let mut usage = last_prompt_usage?; - if usage.context_budget_tokens == 0 && usage.prompt_context_tokens > 0 { - usage.context_budget_tokens = usage.prompt_context_tokens; - } - Some(usage) -} - -async fn restore_model_from_graph_config( - config: Option<&PersistedSessionConfig>, - app: &mut App, - runtime: &mut Option, - provider: &ProviderHandle, - _model_catalog: &CachedModelCatalog, -) -> Result<(), String> { - let Some(config) = config else { - return Ok(()); - }; - let restored_model = config.model.id.as_str(); - if restored_model.is_empty() { - return Ok(()); - } - provider - .validate_model_name(restored_model) - .map_err(|err| { - format!( - "Cannot resume session with model `{}`: {}", - restored_model, err - ) - })?; - let restored_context_window = config.model.context_window_tokens() as u64; - app.model = restored_model.to_string(); - app.usage.context_window = Some(restored_context_window); - if let Some(rt) = runtime.as_mut() { - // Re-attach host capability from the current catalog: persisted specs - // predating the capability field carry an empty one. - let restored_spec = - config - .model - .clone() - .with_capability(crate::capability_catalog::capability_for( - provider.kind(), - restored_model, - )); - let _ = rt - .admin() - .config() - .update(SessionConfigPatch { - model: Some(restored_spec), - ..SessionConfigPatch::default() - }) - .await; - } - Ok(()) -} - -#[allow(clippy::too_many_arguments)] -async fn apply_graph_resume_state( - graph: lash_core::SessionGraph, - config: Option, - _token_ledger: Vec, - checkpoint: Option, - _checkpoint_ref: Option, - history: &mut Vec, - runtime: &mut Option, - app: &mut App, - turn_counter: &mut usize, - execution_mode: &mut ExecutionMode, - provider: &ProviderHandle, - current_model_variant: &mut Option, - active_tool_state: &mut ToolState, - model_catalog: &CachedModelCatalog, -) -> Result<(), String> { - let mut graph = graph; - // If the persisted leaf points to a node that no longer exists - // (compaction rewrote the graph, or the stored session is from - // an older schema), walk back to the most recent message rather - // than projecting an empty transcript. - if graph.heal_orphaned_leaf() { - tracing::warn!("session graph leaf was orphaned on resume; healed to most recent message"); - } - let read_view = lash_core::SessionReadView::from_snapshot(&lash_core::SessionSnapshot { - session_graph: graph.clone(), - ..lash_core::SessionSnapshot::default() - }); - let messages = read_view.messages().to_vec(); - *history = messages.clone(); - - if let Some(tool_state) = checkpoint - .as_ref() - .and_then(|checkpoint| checkpoint.tool_state.clone()) - && let Some(session) = runtime.as_ref() - { - // Cold resume restores the persisted snapshot verbatim (adopting its - // generation), not a generation-checked delta — a session whose tool - // surface reached generation ≥ 2 would be rejected by `apply_state`. - let _ = session - .admin() - .tools() - .advanced() - .restore_state(tool_state) - .await; - if let Ok(state) = session.admin().tools().state().await { - *active_tool_state = state; - } - } - - let turn_state = checkpoint.as_ref().map(|checkpoint| &checkpoint.turn_state); - *turn_counter = turn_state - .as_ref() - .map(|state| state.turn_index) - .unwrap_or(0); - app.usage.token_usage = restored_token_usage(turn_state).unwrap_or_default(); - app.usage.last_prompt_usage = normalized_last_prompt_usage( - turn_state - .as_ref() - .and_then(|state| state.last_prompt_usage.clone()), - ); - - restore_model_from_graph_config(config.as_ref(), app, runtime, provider, model_catalog).await?; - - *current_model_variant = config - .as_ref() - .and_then(|state| { - crate::model_selection::variant_from_reasoning_selection(state.model.variant.clone()) - }) - .or_else(|| crate::model_selection::default_variant(provider, &app.model)); - app.set_model_variant(current_model_variant.clone()); - - let _ = execution_mode; - - let execution_state_snapshot = checkpoint - .as_ref() - .and_then(|checkpoint| checkpoint.execution_state.clone()); - restore_execution_state_if_present(runtime, app, history, execution_state_snapshot.as_deref()) - .await; - - if let Some(rt) = runtime.as_mut() { - rt.admin() - .config() - .update(SessionConfigPatch { - provider: Some(provider.clone()), - model: config.as_ref().map(|config| { - let mut model = config.model.clone(); - model.variant = crate::model_selection::reasoning_selection_from_variant( - current_model_variant.clone(), - ); - model - }), - ..SessionConfigPatch::default() - }) - .await - .map_err(|err| err.to_string())?; - let _ = rt - .admin() - .commands() - .refresh_tool_catalog("resume provider update", "resume-provider-update") - .await; - } - - Ok(()) -} - #[allow(clippy::too_many_arguments)] pub async fn load_resumed_session( - identifier: &str, + _identifier: &str, app: &mut App, - logger: &mut session_log::SessionLogger, + _logger: &mut session_log::SessionLogger, history: &mut Vec, runtime: &mut Option, turn_counter: &mut usize, @@ -253,47 +20,40 @@ pub async fn load_resumed_session( provider: &ProviderHandle, current_model_variant: &mut Option, active_tool_state: &mut ToolState, - model_catalog: &CachedModelCatalog, + _model_catalog: &CachedModelCatalog, ) -> Result<(), String> { - let filename = session_log::filename_for_session_identifier(identifier) - .await - .unwrap_or_else(|| identifier.to_string()); - let loaded = session_log::load_session(&filename) - .await - .map_err(|err| format!("Could not load: {err}"))?; - let store = Arc::new( - Store::open(&session_log::sessions_dir().join(&loaded.filename)) - .await - .map_err(|err| format!("Could not open session database: {err}"))?, - ); - *logger = session_log::SessionLogger::resume(store, &loaded.filename) - .await - .map_err(|err| format!("Could not resume session logger: {err}"))?; + let session = runtime + .as_ref() + .ok_or_else(|| "opened session was not installed".to_string())?; + let loaded = session_log::load_opened_session(session) + .map_err(|error| format!("Could not load session: {error}"))?; *history = loaded.messages; app.timeline = loaded.blocks; app.session_id = loaded.session_id; app.session_name = loaded.session_name; app.usage.last_response_usage = loaded.last_token_usage; app.usage.last_prompt_usage = None; + app.usage.token_usage = session.usage_report().usage.usage; app.plugin_mode_indicators = loaded.plugin_mode_indicators; app.replace_ui_activity_journal(loaded.ui_activity_journal); app.timeline.push(UiTimelineItem::SystemMessage(format!( "Resumed: {}", loaded.filename ))); - restore_session_state( - &filename, - history, - runtime, - app, - turn_counter, - execution_mode, - provider, - current_model_variant, - active_tool_state, - model_catalog, - ) - .await?; + let policy = session.policy_snapshot(); + app.model = policy.model.id.clone(); + app.usage.context_window = Some(policy.model.context_window_tokens() as u64); + *current_model_variant = + crate::model_selection::variant_from_reasoning_selection(policy.model.variant); + app.set_model_variant(current_model_variant.clone()); + *turn_counter = session.read_view().turn_index(); + *active_tool_state = session + .admin() + .tools() + .state() + .await + .map_err(|error| error.to_string())?; + let _ = provider; app.stop_turn(); app.live.tool_output = loaded.live_tool_output; app.set_execution_mode_label(execution_mode); @@ -301,211 +61,3 @@ pub async fn load_resumed_session( app.resume_follow_output(); Ok(()) } - -#[allow(clippy::too_many_arguments)] -pub async fn restore_session_state( - session_filename: &str, - history: &mut Vec, - runtime: &mut Option, - app: &mut App, - turn_counter: &mut usize, - execution_mode: &mut ExecutionMode, - provider: &ProviderHandle, - current_model_variant: &mut Option, - active_tool_state: &mut ToolState, - model_catalog: &CachedModelCatalog, -) -> Result<(), String> { - let db_path = session_log::sessions_dir().join(session_filename); - - if !db_path.exists() { - push_history_system_message(history, execution_state_reset_message()); - return Ok(()); - } - - let resume_store = match Store::open(&db_path).await { - Ok(s) => s, - Err(err) => { - app.timeline.push(UiTimelineItem::SystemMessage(format!( - "Could not open session database: {err}" - ))); - return Ok(()); - } - }; - - if let Some(head) = resume_store.load_session_head().await { - let checkpoint = match head.checkpoint_ref.as_ref() { - Some(blob_ref) => resume_store.get_checkpoint(blob_ref).await, - None => None, - }; - return apply_graph_resume_state( - head.graph, - Some(head.config), - head.token_ledger, - checkpoint, - head.checkpoint_ref, - history, - runtime, - app, - turn_counter, - execution_mode, - provider, - current_model_variant, - active_tool_state, - model_catalog, - ) - .await; - } - - if runtime.is_some() { - push_history_system_message(history, execution_state_reset_message()); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_support::{EnvVarGuard, TempDirGuard, env_lock}; - - use crate::model_catalog::MemoryModelCatalogStore; - - async fn persist_session_head( - store: &Store, - graph: lash_core::SessionGraph, - checkpoint: lash_core::store::HydratedSessionCheckpoint, - ) { - let checkpoint_ref = store.put_checkpoint(&checkpoint).await.checkpoint_ref; - store - .save_session_head(lash_core::store::SessionHead { - session_id: "root".to_string(), - head_revision: 0, - agent_frames: Vec::new(), - current_agent_frame_id: String::new(), - graph, - config: lash_core::PersistedSessionConfig { - provider_id: "openai_generic".to_string(), - model: lash_core::ModelSpec::from_token_limits( - "gpt-5", - Default::default(), - 200_000, - None, - ) - .expect("valid model spec"), - }, - checkpoint_ref: Some(checkpoint_ref), - token_ledger: Vec::new(), - }) - .await; - } - - fn state_with_graph( - messages: Vec, - iteration: usize, - token_usage: TokenUsage, - last_prompt_usage: Option, - ) -> ( - lash_core::SessionGraph, - lash_core::store::HydratedSessionCheckpoint, - ) { - ( - lash_core::SessionGraph::from_active_read_state(&messages), - lash_core::store::HydratedSessionCheckpoint { - turn_state: lash_core::PersistedTurnState { - turn_index: iteration, - token_usage, - last_prompt_usage, - protocol_turn_options: Default::default(), - }, - tool_state_ref: None, - tool_state: Some(ToolState::default()), - plugin_snapshot_ref: None, - plugin_snapshot_revision: None, - plugin_snapshot: None, - execution_state_ref: None, - execution_state: None, - }, - ) - } - - fn build_model_catalog() -> CachedModelCatalog { - CachedModelCatalog::models_dev(Arc::new(MemoryModelCatalogStore::new(None)), None) - .expect("catalog") - } - - #[tokio::test] - async fn restore_session_state_restores_graph_turn_state_into_app_and_runtime() { - let _env_guard = env_lock().lock().await; - let temp = TempDirGuard::new("lash-resume-usage"); - let _lash_home = EnvVarGuard::set("LASH_HOME", temp.path()); - let sessions_dir = crate::paths::lash_home().join("sessions"); - std::fs::create_dir_all(&sessions_dir).expect("sessions dir"); - - let db_path = sessions_dir.join("resume-usage.db"); - let store = Store::open(&db_path).await.expect("store"); - let (graph, checkpoint) = state_with_graph( - Vec::new(), - 7, - TokenUsage { - input_tokens: 1200, - output_tokens: 340, - cache_read_input_tokens: 80, - cache_write_input_tokens: 0, - reasoning_output_tokens: 55, - }, - Some(PromptUsage { - prompt_context_tokens: 4096, - input_tokens: 3900, - cache_read_input_tokens: 196, - cache_write_input_tokens: 0, - context_budget_tokens: 0, - }), - ); - persist_session_head(&store, graph, checkpoint).await; - - let provider = lash_core::ProviderHandle::new( - lash_provider_openai::OpenAiCompatibleProvider::new( - "test-key", - "https://example.invalid/v1", - ) - .into_components(), - ); - let mut active_tool_state = ToolState::default(); - let model_catalog = build_model_catalog(); - - let mut app = App::new( - "gpt-5".into(), - "resume-usage".into(), - "test-session-id".into(), - ); - let mut history = Vec::new(); - let mut runtime = None; - let mut turn_counter = 0; - let mut execution_mode = ExecutionMode::Standard; - let mut current_model_variant = None; - - restore_session_state( - "resume-usage.db", - &mut history, - &mut runtime, - &mut app, - &mut turn_counter, - &mut execution_mode, - &provider, - &mut current_model_variant, - &mut active_tool_state, - &model_catalog, - ) - .await - .expect("restore"); - - assert_eq!(turn_counter, 7); - assert_eq!(execution_mode, ExecutionMode::Standard); - assert_eq!(app.usage.token_usage.input_tokens, 1200); - assert_eq!(app.usage.token_usage.output_tokens, 340); - assert_eq!(app.usage.token_usage.cache_read_input_tokens, 80); - assert_eq!(app.usage.token_usage.reasoning_output_tokens, 55); - let app_prompt_usage = app.usage.last_prompt_usage.expect("app prompt usage"); - assert_eq!(app_prompt_usage.prompt_context_tokens, 4096); - assert_eq!(app_prompt_usage.context_budget_tokens, 4096); - } -} diff --git a/crates/lash-cli/src/session_log.rs b/crates/lash-cli/src/session_log.rs index c9a42ea..386616c 100644 --- a/crates/lash-cli/src/session_log.rs +++ b/crates/lash-cli/src/session_log.rs @@ -1,16 +1,11 @@ use std::cmp::Reverse; -use std::collections::BTreeMap; use std::io::{BufRead, Write}; use std::path::{Path, PathBuf}; -use std::sync::Arc; use std::time::SystemTime; -use anyhow::Result; +use anyhow::{Context, Result}; +use lash_core::TokenUsage; use lash_core::session_model::Message; -#[cfg(test)] -use lash_core::session_model::{MessageRole, PartKind}; -use lash_core::{SessionSnapshot, TokenUsage}; -use lash_sqlite_store::Store; use crate::app::{ LiveToolOutput, PreparedTurn, UiActivityJournal, UiActivityRecord, UiTimeline, UiTimelineItem, @@ -28,7 +23,6 @@ pub struct SessionInfo { } pub struct SessionStart { - pub session_id: String, pub session_name: String, } @@ -39,184 +33,168 @@ pub struct LoadedSession { pub messages: Vec, pub blocks: UiTimeline, pub last_token_usage: TokenUsage, - pub plugin_mode_indicators: BTreeMap, + pub plugin_mode_indicators: std::collections::BTreeMap, pub live_tool_output: LiveToolOutput, pub(crate) ui_activity_journal: UiActivityJournal, } -pub struct SessionLogger { - store: Arc, - pub session_id: String, - filename: String, -} - -#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] -struct HostUiSidecar { +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub(crate) struct RosterEntry { + pub(crate) session_id: String, + pub(crate) session_name: String, + pub(crate) model: String, + pub(crate) created_at: String, + pub(crate) cwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) parent_session_id: Option, + #[serde(default)] + pub(crate) message_count: usize, + #[serde(default)] + pub(crate) first_message: String, #[serde(default)] - inputs: Vec, + pub(crate) inputs: Vec, } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -struct HostInputRecord { +pub(crate) struct HostInputRecord { display_text: String, effective_text: String, } +pub struct SessionLogger { + pub session_id: String, + filename: String, +} + impl SessionLogger { - pub async fn new( - store: Arc, - filename: String, - model: &str, - session_id: Option, + pub fn new( + session_id: String, session_name: String, + model: &str, + parent_session_id: Option, ) -> Result { std::fs::create_dir_all(sessions_dir())?; - let session_id = session_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); - store - .save_session_meta(lash_core::SessionMeta { - session_id: session_id.clone(), - session_name: session_name.clone(), - created_at: chrono::Local::now().to_rfc3339(), - model: model.to_string(), - cwd: std::env::current_dir() - .ok() - .and_then(|path| path.to_str().map(str::to_string)), - relation: lash_core::SessionRelation::Root, - }) - .await; - Ok(Self { - store, - session_id, - filename, - }) - } - - pub async fn resume(store: Arc, filename: &str) -> Result { - let start = store - .load_session_meta() - .await - .map(|meta| SessionStart { - session_id: meta.session_id, - session_name: meta.session_name, - }) - .ok_or_else(|| anyhow::anyhow!("Could not load session metadata for {}", filename))?; + let entry = load_roster_entry(&session_id).unwrap_or_else(|_| RosterEntry { + session_id: session_id.clone(), + session_name, + model: model.to_string(), + created_at: chrono::Local::now().to_rfc3339(), + cwd: std::env::current_dir().ok(), + parent_session_id, + message_count: 0, + first_message: String::new(), + inputs: Vec::new(), + }); + save_roster_entry(&entry)?; Ok(Self { - store, - session_id: start.session_id, - filename: filename.to_string(), + session_id: session_id.clone(), + filename: session_id, }) } - pub fn store(&self) -> &Arc { - &self.store - } - pub fn db_path(&self) -> PathBuf { - sessions_dir().join(&self.filename) - } - - fn ui_sidecar_path_for(filename: &str) -> PathBuf { - sessions_dir().join(format!("{filename}.ui.json")) - } - - fn ui_activity_log_path_for(filename: &str) -> PathBuf { - sessions_dir().join(format!("{filename}.ui-activity.jsonl")) + crate::paths::durable_core_db() } - fn ui_sidecar_path(&self) -> PathBuf { - Self::ui_sidecar_path_for(&self.filename) + fn ui_sidecar_path_for(session_id: &str) -> PathBuf { + sessions_dir().join(format!("{session_id}.ui.json")) } - fn ui_activity_log_path(&self) -> PathBuf { - Self::ui_activity_log_path_for(&self.filename) + fn ui_activity_log_path_for(session_id: &str) -> PathBuf { + sessions_dir().join(format!("{session_id}.ui-activity.jsonl")) } pub fn record_host_input(&self, turn: &PreparedTurn) -> Result<()> { - if turn.display_text.trim().is_empty() || turn.display_text == turn.effective_text { - return Ok(()); + let mut roster = load_roster_entry(&self.session_id)?; + roster.message_count = roster.message_count.saturating_add(1); + if roster.first_message.is_empty() { + roster.first_message = turn.display_text.trim().to_string(); } - let path = self.ui_sidecar_path(); - let mut sidecar = load_host_ui_sidecar_path(&path).unwrap_or_default(); - sidecar.inputs.push(HostInputRecord { - display_text: turn.display_text.clone(), - effective_text: turn.effective_text.clone(), - }); - std::fs::write(path, serde_json::to_vec_pretty(&sidecar)?)?; - Ok(()) + if !turn.display_text.trim().is_empty() && turn.display_text != turn.effective_text { + roster.inputs.push(HostInputRecord { + display_text: turn.display_text.clone(), + effective_text: turn.effective_text.clone(), + }); + } + save_roster_entry(&roster) } pub(crate) fn append_ui_activity_records(&self, records: &[UiActivityRecord]) -> Result<()> { if records.is_empty() { return Ok(()); } - let path = self.ui_activity_log_path(); let mut file = std::fs::OpenOptions::new() .create(true) .append(true) - .open(path)?; + .open(Self::ui_activity_log_path_for(&self.filename))?; for record in records { serde_json::to_writer(&mut file, record)?; file.write_all(b"\n")?; } Ok(()) } +} - pub async fn mark_as_child_of(&self, parent_session_id: &str) -> Result<()> { - let meta = self - .store - .load_session_meta() - .await - .ok_or_else(|| anyhow::anyhow!("Missing session metadata for {}", self.filename))?; - self.store - .save_session_meta(lash_core::SessionMeta { - session_id: meta.session_id, - session_name: meta.session_name, - created_at: meta.created_at, - model: meta.model, - cwd: meta.cwd, - relation: lash_core::SessionRelation::Child { - parent_session_id: parent_session_id.to_string(), - caused_by: None, - }, - }) - .await; - Ok(()) - } +pub(crate) fn save_roster_entry(entry: &RosterEntry) -> Result<()> { + std::fs::create_dir_all(sessions_dir())?; + std::fs::write( + SessionLogger::ui_sidecar_path_for(&entry.session_id), + serde_json::to_vec_pretty(entry)?, + )?; + Ok(()) } -fn load_host_ui_sidecar_path(path: &Path) -> Result { - match std::fs::read(path) { - Ok(bytes) => Ok(serde_json::from_slice(&bytes)?), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(HostUiSidecar::default()), - Err(err) => Err(err.into()), - } +pub(crate) fn load_roster_entry(session_id: &str) -> Result { + let path = SessionLogger::ui_sidecar_path_for(session_id); + let bytes = std::fs::read(&path) + .with_context(|| format!("could not read session roster entry {}", path.display()))?; + Ok(serde_json::from_slice(&bytes)?) } -fn load_ui_activity_journal_path(path: &Path) -> Result { +pub(crate) fn save_host_config( + session_id: &str, + config: &crate::startup::session::CliSessionHostConfig, +) -> Result<()> { + std::fs::create_dir_all(sessions_dir())?; + std::fs::write( + sessions_dir().join(format!("{session_id}.host.json")), + serde_json::to_vec_pretty(config)?, + )?; + Ok(()) +} + +pub(crate) fn load_host_config( + session_id: &str, +) -> Result { + let bytes = std::fs::read(sessions_dir().join(format!("{session_id}.host.json")))?; + Ok(serde_json::from_slice(&bytes)?) +} + +fn load_ui_activity_journal(session_id: &str) -> Result { + let path = SessionLogger::ui_activity_log_path_for(session_id); let file = match std::fs::File::open(path) { Ok(file) => file, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { return Ok(UiActivityJournal::default()); } - Err(err) => return Err(err.into()), + Err(error) => return Err(error.into()), }; let mut journal = UiActivityJournal::default(); for line in std::io::BufReader::new(file).lines() { let line = line?; - if line.trim().is_empty() { - continue; + if !line.trim().is_empty() { + journal.apply_record(serde_json::from_str(&line)?); } - journal.apply_record(serde_json::from_str::(&line)?); } Ok(journal) } -fn apply_host_input_sidecar(blocks: &mut UiTimeline, sidecar: &HostUiSidecar) { - for item in blocks.iter_mut() { +fn apply_host_inputs(timeline: &mut UiTimeline, roster: &RosterEntry) { + for item in timeline.iter_mut() { let UiTimelineItem::UserInput(text) = item else { continue; }; - if let Some(record) = sidecar + if let Some(record) = roster .inputs .iter() .find(|record| record.effective_text.trim() == text.trim()) @@ -226,870 +204,146 @@ fn apply_host_input_sidecar(blocks: &mut UiTimeline, sidecar: &HostUiSidecar) { } } +pub(crate) fn load_opened_session(session: &lash::LashSession) -> Result { + let session_id = session.session_id(); + let roster = load_roster_entry(&session_id)?; + let read_view = session.read_view(); + let activity_journal = load_ui_activity_journal(&session_id)?; + let ui_state = crate::app::UiProjectionState { + activity_journal, + ..crate::app::UiProjectionState::default() + }; + let mut blocks = timeline_from_read_view(&read_view, &ui_state); + apply_host_inputs(&mut blocks, &roster); + Ok(LoadedSession { + session_id: session_id.clone(), + session_name: roster.session_name, + filename: session_id, + messages: read_view.messages().to_vec(), + blocks, + last_token_usage: TokenUsage::default(), + plugin_mode_indicators: ui_state.plugin_mode_indicators.clone(), + live_tool_output: ui_state.live_tool_output.clone(), + ui_activity_journal: ui_state.activity_journal, + }) +} + impl SessionInfo { pub fn relative_time(&self) -> String { - let elapsed = self.modified.elapsed().unwrap_or_default(); - let secs = elapsed.as_secs(); - if secs < 60 { - "just now".to_string() - } else if secs < 3600 { - format!("{}m ago", secs / 60) - } else if secs < 86400 { - format!("{}h ago", secs / 3600) - } else if secs < 604800 { - format!("{}d ago", secs / 86400) - } else { - format!("{}w ago", secs / 604800) + let secs = self.modified.elapsed().unwrap_or_default().as_secs(); + match secs { + 0..60 => "just now".to_string(), + 60..3600 => format!("{}m ago", secs / 60), + 3600..86400 => format!("{}h ago", secs / 3600), + 86400..604800 => format!("{}d ago", secs / 86400), + _ => format!("{}w ago", secs / 604800), } } pub fn cwd_label(&self) -> Option { let cwd = self.cwd.as_deref()?; - let name = cwd - .file_name() + cwd.file_name() .and_then(|name| name.to_str()) .filter(|name| !name.is_empty()) - .map(|name| format!("/{name}")); - if name.is_some() { - name - } else if cwd == Path::new("/") { - Some("/".to_string()) - } else { - None - } + .map(|name| format!("/{name}")) + .or_else(|| (cwd == Path::new("/")).then(|| "/".to_string())) } } pub fn sessions_dir() -> PathBuf { - crate::paths::lash_home().join("sessions") -} - -pub fn new_session_filename() -> String { - format!("{}.db", chrono::Local::now().format("%Y%m%d_%H%M%S")) -} - -fn is_resumable_session_store(path: &Path) -> bool { - if path.extension().and_then(|ext| ext.to_str()) != Some("db") { - return false; - } - // Component sidecars live next to the session store as - // `.db..db` (effects, processes, triggers, - // artifacts); anything with an interior `.db.` segment is a sidecar, - // not a resumable session store. - path.file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| !name.contains(".db.")) -} - -async fn parse_session_info( - path: &Path, - filename: String, - modified: SystemTime, -) -> Option { - let store = Store::open_readonly(path).await.ok()?; - let info = store.load_picker_info().await?; - if info.parent_session_id().is_some() { - return None; - } - - Some(SessionInfo { - filename, - session_id: info.session_id, - message_count: info.user_message_count, - first_message: info.first_user_message, - modified, - cwd: info.cwd.map(PathBuf::from), - }) + crate::paths::sessions_dir() } -fn collect_session_candidates() -> Vec<(PathBuf, String, SystemTime)> { - let mut candidates = Vec::new(); - let entries = match std::fs::read_dir(sessions_dir()) { - Ok(entries) => entries, - Err(_) => return candidates, - }; - for entry in entries.flatten() { - let path = entry.path(); - if !is_resumable_session_store(&path) { - continue; - } - let modified = std::fs::metadata(&path) - .and_then(|meta| meta.modified()) - .unwrap_or(SystemTime::UNIX_EPOCH); - let Some(filename) = path - .file_name() - .map(|name| name.to_string_lossy().to_string()) - else { - continue; - }; - candidates.push((path, filename, modified)); +pub async fn filename_for_session_identifier(identifier: &str) -> Option { + if load_roster_entry(identifier).is_ok() { + return Some(identifier.to_string()); } - candidates.sort_by_key(|candidate| Reverse(candidate.2)); - candidates + list_recent_sessions(usize::MAX) + .await + .into_iter() + .find(|entry| { + load_roster_entry(&entry.session_id) + .is_ok_and(|roster| roster.session_name == identifier) + }) + .map(|entry| entry.session_id) } -pub async fn load_session_start(filename: &str) -> Result { - let store = Store::open(&sessions_dir().join(filename)).await?; - let meta = store - .load_session_meta() +pub async fn load_session_start(identifier: &str) -> Result { + let session_id = filename_for_session_identifier(identifier) .await - .ok_or_else(|| anyhow::anyhow!("Could not load session metadata for {}", filename))?; + .ok_or_else(|| anyhow::anyhow!("Could not resolve session `{identifier}`"))?; + let roster = load_roster_entry(&session_id)?; Ok(SessionStart { - session_id: meta.session_id, - session_name: meta.session_name, + session_name: roster.session_name, }) } -async fn filename_for_session_meta( - candidates: Vec<(PathBuf, String, SystemTime)>, - mut matches: impl FnMut(&lash_core::SessionMeta) -> bool, -) -> Option { - for (path, filename, _) in candidates { - // A candidate that fails to open or carries no session meta (an - // unreadable file, or a store that has never committed) must not - // abort the whole search — skip it and keep looking. - let Ok(store) = Store::open_readonly(&path).await else { - continue; - }; - let Some(meta) = store.load_session_meta().await else { - continue; - }; - if matches(&meta) { - return Some(filename); - } - } - None -} - -pub(crate) async fn filename_for_session_identifier(identifier: &str) -> Option { - let identifier = identifier.trim(); - if identifier.is_empty() { - return None; - } - - let candidates = collect_session_candidates(); - if candidates - .iter() - .any(|(_, filename, _)| filename == identifier) - { - return Some(identifier.to_string()); - } - - filename_for_session_meta(candidates, |meta| { - meta.session_id == identifier || meta.session_name == identifier - }) - .await -} - pub async fn list_recent_sessions(limit: usize) -> Vec { - if limit == 0 { - return Vec::new(); - } - - // Candidates are pre-sorted by modified time (latest first). - let mut sessions = Vec::new(); - for (path, filename, modified) in collect_session_candidates() { - if let Some(info) = parse_session_info(&path, filename, modified).await { - sessions.push(info); - if sessions.len() >= limit { - break; + let mut sessions = std::fs::read_dir(sessions_dir()) + .into_iter() + .flatten() + .flatten() + .filter_map(|entry| { + let path = entry.path(); + let name = path.file_name()?.to_str()?; + let session_id = name.strip_suffix(".ui.json")?; + let roster = load_roster_entry(session_id).ok()?; + if roster.parent_session_id.is_some() { + return None; } - } - } - sessions.sort_by_key(|session| Reverse(session.modified)); + let modified = entry + .metadata() + .and_then(|metadata| metadata.modified()) + .unwrap_or(SystemTime::UNIX_EPOCH); + Some(SessionInfo { + filename: session_id.to_string(), + session_id: session_id.to_string(), + message_count: roster.message_count, + first_message: roster.first_message, + modified, + cwd: roster.cwd, + }) + }) + .collect::>(); + sessions.sort_by_key(|entry| Reverse(entry.modified)); + sessions.truncate(limit); sessions } -pub async fn load_session(filename: &str) -> Result { - let store = Store::open(&sessions_dir().join(filename)).await?; - let meta = store - .load_session_meta() - .await - .ok_or_else(|| anyhow::anyhow!("Could not load session metadata for {}", filename))?; - let head = store.load_session_head().await.unwrap_or_default(); - let checkpoint_ref = head.checkpoint_ref.clone(); - let state = SessionSnapshot { - session_id: head.session_id, - session_graph: head.graph, - ..SessionSnapshot::default() - }; - let read_view = state.read_view(); - let messages = read_view.messages().to_vec(); - let sidecar = load_host_ui_sidecar_path(&SessionLogger::ui_sidecar_path_for(filename)) - .unwrap_or_default(); - let activity_journal = - load_ui_activity_journal_path(&SessionLogger::ui_activity_log_path_for(filename)) - .unwrap_or_default(); - let ui_state = crate::app::UiProjectionState { - activity_journal, - ..crate::app::UiProjectionState::default() - }; - let checkpoint = checkpoint_ref - .as_ref() - .map(|blob_ref| async { store.get_checkpoint(blob_ref).await }); - let checkpoint = match checkpoint { - Some(checkpoint) => checkpoint.await, - None => None, - }; - let plugin_mode_indicators = ui_state.plugin_mode_indicators.clone(); - let live_tool_output = ui_state.live_tool_output.clone(); - let mut blocks = timeline_from_read_view(&read_view, &ui_state); - apply_host_input_sidecar(&mut blocks, &sidecar); - tracing::debug!( - session_file = filename, - messages = read_view.messages().len(), - blocks = blocks.len(), - plugin_mode_indicators = plugin_mode_indicators.len(), - graph_nodes = read_view.materialized_session_graph().nodes.len(), - "loaded persisted session snapshot" - ); - - Ok(LoadedSession { - session_id: meta.session_id, - session_name: meta.session_name, - filename: filename.to_string(), - messages, - blocks, - last_token_usage: checkpoint - .map(|checkpoint| checkpoint.turn_state.token_usage) - .unwrap_or_default(), - plugin_mode_indicators, - live_tool_output, - ui_activity_journal: ui_state.activity_journal, - }) +pub(crate) fn incompatible_session_count() -> usize { + std::fs::read_dir(sessions_dir()) + .into_iter() + .flatten() + .flatten() + .filter(|entry| { + entry + .path() + .extension() + .is_some_and(|extension| extension == "db") + }) + .count() } -#[cfg(test)] -mod tests { - use super::*; - use crate::activity::{ActivityBlock, ActivityKind, ActivityStatus}; - use crate::test_support::{EnvVarGuard, TempDirGuard, env_lock}; - - fn block_on(future: impl std::future::Future) -> T { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("runtime") - .block_on(future) - } - - fn with_temp_lash_home(test_name: &str, f: impl FnOnce()) { - let _env_guard = env_lock().blocking_lock(); - let temp = TempDirGuard::new(test_name); - let _lash_home = EnvVarGuard::set("LASH_HOME", temp.path()); - std::fs::create_dir_all(sessions_dir()).expect("sessions dir"); - f(); - } - - async fn persist_root_snapshot(store: &Store, messages: Vec, token_usage: TokenUsage) { - let graph = lash_core::SessionGraph::from_active_read_state(&messages); - let checkpoint_ref = store - .put_checkpoint(&lash_core::store::HydratedSessionCheckpoint { - turn_state: lash_core::PersistedTurnState { - turn_index: 1, - token_usage, - last_prompt_usage: None, - protocol_turn_options: Default::default(), - }, - tool_state_ref: None, - tool_state: Some(lash_core::ToolState::default()), - plugin_snapshot_ref: None, - plugin_snapshot_revision: None, - plugin_snapshot: None, - execution_state_ref: None, - execution_state: None, - }) - .await - .checkpoint_ref; - store - .save_session_head(lash_core::store::SessionHead { - session_id: "root".to_string(), - head_revision: 0, - agent_frames: Vec::new(), - current_agent_frame_id: String::new(), - graph, - config: lash_core::PersistedSessionConfig { - provider_id: "openai_generic".to_string(), - model: lash_core::ModelSpec::from_token_limits( - "gpt-test", - Default::default(), - 200_000, - None, - ) - .expect("valid model spec"), - }, - checkpoint_ref: Some(checkpoint_ref), - token_ledger: Vec::new(), - }) - .await; - } - - fn text_message(role: MessageRole, id: &str, content: &str) -> Message { - Message { - id: id.to_string(), - role, - parts: vec![lash_core::Part { - id: format!("{id}.p0"), - kind: PartKind::Text, - content: content.to_string(), - attachment: None, - tool_call_id: None, - tool_name: None, - tool_replay: None, - prune_state: lash_core::PruneState::Intact, - reasoning_meta: None, - response_meta: None, - }] - .into(), - origin: None, - } - } - - fn tool_result_message(id: &str, call_id: &str, tool_name: &str) -> Message { - Message { - id: id.to_string(), - role: MessageRole::User, - parts: vec![lash_core::Part { - id: format!("{id}.p0"), - kind: PartKind::ToolResult, - content: String::new(), - attachment: None, - tool_call_id: Some(call_id.to_string()), - tool_name: Some(tool_name.to_string()), - tool_replay: None, - prune_state: lash_core::PruneState::Intact, - reasoning_meta: None, - response_meta: None, - }] - .into(), - origin: None, - } - } - - #[test] - fn load_session_reads_persisted_root_snapshot() { - with_temp_lash_home("lash-session-load-root-snapshot", || { - block_on(async { - let filename = new_session_filename(); - let path = sessions_dir().join(&filename); - let store = Arc::new(Store::open(&path).await.unwrap()); - SessionLogger::new( - Arc::clone(&store), - filename.clone(), - "gpt-test", - Some("s1".into()), - "demo".into(), - ) - .await - .unwrap(); - let messages = vec![ - text_message(MessageRole::User, "m0", "Hi"), - text_message(MessageRole::Assistant, "m1", "Hello world"), - ]; - persist_root_snapshot( - &store, - messages.clone(), - TokenUsage { - input_tokens: 12, - output_tokens: 7, - cache_read_input_tokens: 2, - cache_write_input_tokens: 0, - reasoning_output_tokens: 1, - }, - ) - .await; - - let loaded = load_session(&filename).await.unwrap(); - assert_eq!(loaded.messages.len(), 2); - // blocks[0] is the TurnStart marker the projection emits above - // the first user input. - assert!(matches!( - loaded.blocks.first(), - Some(UiTimelineItem::TurnStart(_)) - )); - assert!(matches!( - loaded.blocks.get(1), - Some(UiTimelineItem::UserInput(text)) if text == "Hi" - )); - assert!(matches!( - loaded.blocks.get(2), - Some(UiTimelineItem::AssistantText(text)) if text == "Hello world" - )); - assert_eq!(loaded.last_token_usage.input_tokens, 12); - assert!(loaded.plugin_mode_indicators.is_empty()); - assert!(loaded.live_tool_output.lines.is_empty()); - assert_eq!(loaded.live_tool_output.hidden, 0); - assert!(loaded.live_tool_output.partial.is_empty()); - }); - }); - } - - #[test] - fn load_session_does_not_hydrate_activity_blocks_from_detached_tool_history() { - with_temp_lash_home("lash-session-load-activity-blocks", || { - block_on(async { - let filename = new_session_filename(); - let path = sessions_dir().join(&filename); - let store = Arc::new(Store::open(&path).await.unwrap()); - SessionLogger::new( - Arc::clone(&store), - filename.clone(), - "gpt-test", - Some("s-activity".into()), - "demo".into(), - ) - .await - .unwrap(); - let messages = vec![ - text_message(MessageRole::User, "m0", "inspect repo"), - tool_result_message("m1", "call-shell", "exec_command"), - text_message(MessageRole::Assistant, "m2", "Done"), - ]; - persist_root_snapshot(&store, messages, TokenUsage::default()).await; - - let loaded = load_session(&filename).await.unwrap(); - // blocks[0] = TurnStart, [1] = UserInput, [2] = AssistantText. - // Detached tool invocation records are no longer persisted, - // so a bare tool-result protocol message is not enough to - // synthesize an activity block on resume. - assert!(matches!( - loaded.blocks.first(), - Some(UiTimelineItem::TurnStart(_)) - )); - assert!(matches!( - loaded.blocks.get(2), - Some(UiTimelineItem::AssistantText(text)) if text == "Done" - )); - }); - }); - } - - #[test] - fn load_session_projects_cli_owned_ui_activity_journal() { - with_temp_lash_home("lash-session-load-ui-activity-journal", || { - block_on(async { - let filename = new_session_filename(); - let path = sessions_dir().join(&filename); - let store = Arc::new(Store::open(&path).await.unwrap()); - SessionLogger::new( - Arc::clone(&store), - filename.clone(), - "gpt-test", - Some("s-ui-activity".into()), - "demo".into(), - ) - .await - .unwrap(); - let messages = vec![ - text_message(MessageRole::User, "m0", "inspect repo"), - text_message(MessageRole::Assistant, "m1", "Done"), - ]; - persist_root_snapshot(&store, messages, TokenUsage::default()).await; - - let mut activity_journal = UiActivityJournal::default(); - let record = UiActivityRecord::new( - 0, - 0, - ActivityBlock::new( - ActivityKind::GenericTool, - "exec_command", - serde_json::json!({"cmd": "pwd"}), - "Run pwd", - ActivityStatus::Completed, - serde_json::json!({"exit_code": 0, "stdout": "/workspace/code/lash\n"}), - 4, - ) - .with_call_id(Some("lashlang:tool-0".to_string())), - ); - activity_journal.apply_record(record.clone()); - let logger = SessionLogger::resume(Arc::clone(&store), &filename) - .await - .unwrap(); - logger.append_ui_activity_records(&[record]).unwrap(); - - let loaded = load_session(&filename).await.unwrap(); - assert_eq!(loaded.ui_activity_journal, activity_journal); - assert!(loaded.blocks.iter().any(|block| matches!( - block, - UiTimelineItem::Activity(activity) - if activity.call.call_id.as_deref() == Some("lashlang:tool-0") - && activity.result.status == ActivityStatus::Completed - ))); - }); - }); - } - - #[test] - fn ui_activity_log_does_not_persist_large_raw_tool_outputs() { - with_temp_lash_home("lash-session-ui-activity-omits-raw", || { - block_on(async { - let filename = new_session_filename(); - let path = sessions_dir().join(&filename); - let store = Arc::new(Store::open(&path).await.unwrap()); - let logger = SessionLogger::new( - Arc::clone(&store), - filename.clone(), - "gpt-test", - Some("s-ui-raw".into()), - "demo".into(), - ) - .await - .unwrap(); - let large_raw = "x".repeat(128 * 1024); - let record = UiActivityRecord::new( - 0, - 0, - ActivityBlock::new( - ActivityKind::GenericTool, - "exec_command", - serde_json::json!({"cmd": large_raw}), - "Run command", - ActivityStatus::Completed, - serde_json::json!({"stdout": large_raw, "exit_code": 0}), - 12, - ) - .with_call_id(Some("lashlang:tool-raw".to_string())), - ); - - logger.append_ui_activity_records(&[record]).unwrap(); - - let sidecar = - std::fs::read_to_string(SessionLogger::ui_activity_log_path_for(&filename)) - .unwrap(); - assert!(!sidecar.contains("\"raw\"")); - assert!(!sidecar.contains("\"args\"")); - assert!(sidecar.len() < 4096, "sidecar was {} bytes", sidecar.len()); - }); - }); - } - - #[test] - fn ui_activity_log_replays_started_and_completed_as_one_completed_row() { - with_temp_lash_home("lash-session-ui-activity-replace-running", || { - block_on(async { - let filename = new_session_filename(); - let path = sessions_dir().join(&filename); - let store = Arc::new(Store::open(&path).await.unwrap()); - SessionLogger::new( - Arc::clone(&store), - filename.clone(), - "gpt-test", - Some("s-ui-replay".into()), - "demo".into(), - ) - .await - .unwrap(); - let messages = vec![ - text_message(MessageRole::User, "m0", "inspect repo"), - text_message(MessageRole::Assistant, "m1", "Done"), - ]; - persist_root_snapshot(&store, messages, TokenUsage::default()).await; - let logger = SessionLogger::resume(Arc::clone(&store), &filename) - .await - .unwrap(); - let started = UiActivityRecord::new( - 0, - 0, - ActivityBlock::new( - ActivityKind::ShellCommand, - "exec_command", - serde_json::json!({"cmd": "pwd"}), - "Run pwd", - ActivityStatus::Running, - serde_json::Value::Null, - 0, - ) - .with_call_id(Some("lashlang:tool-0".to_string())), - ); - let completed = UiActivityRecord::new( - 0, - 0, - ActivityBlock::new( - ActivityKind::ShellCommand, - "exec_command", - serde_json::json!({"cmd": "pwd"}), - "Run pwd", - ActivityStatus::Completed, - serde_json::json!({"stdout": "/workspace/code/lash\n", "exit_code": 0}), - 4, - ) - .with_call_id(Some("lashlang:tool-0".to_string())), - ); - logger - .append_ui_activity_records(&[started, completed]) - .unwrap(); - - let loaded = load_session(&filename).await.unwrap(); - let activities = loaded - .ui_activity_journal - .entries() - .iter() - .flat_map(|entry| entry.items.iter()) - .filter_map(|item| match item { - UiTimelineItem::Activity(activity) => Some(activity), - _ => None, - }) - .collect::>(); - assert_eq!(activities.len(), 1); - assert_eq!( - activities[0].call.call_id.as_deref(), - Some("lashlang:tool-0") - ); - assert_eq!(activities[0].result.status, ActivityStatus::Completed); - }); - }); - } - - #[test] - fn ui_activity_log_appends_one_record_per_tool_event() { - with_temp_lash_home("lash-session-ui-activity-appends", || { - block_on(async { - let filename = new_session_filename(); - let path = sessions_dir().join(&filename); - let store = Arc::new(Store::open(&path).await.unwrap()); - let logger = SessionLogger::new( - Arc::clone(&store), - filename.clone(), - "gpt-test", - Some("s-ui-append".into()), - "demo".into(), - ) - .await - .unwrap(); - let first = UiActivityRecord::new( - 0, - 0, - ActivityBlock::new( - ActivityKind::GenericTool, - "exec_command", - serde_json::json!({"cmd": "pwd"}), - "Run pwd", - ActivityStatus::Running, - serde_json::Value::Null, - 0, - ) - .with_call_id(Some("lashlang:tool-0".to_string())), - ); - logger.append_ui_activity_records(&[first]).unwrap(); - let first_write = - std::fs::read_to_string(SessionLogger::ui_activity_log_path_for(&filename)) - .unwrap(); - assert_eq!(first_write.lines().count(), 1); - - let second = UiActivityRecord::new( - 0, - 0, - ActivityBlock::new( - ActivityKind::GenericTool, - "exec_command", - serde_json::json!({"cmd": "pwd"}), - "Run pwd", - ActivityStatus::Completed, - serde_json::json!({"stdout": "/workspace/code/lash\n", "exit_code": 0}), - 3, - ) - .with_call_id(Some("lashlang:tool-0".to_string())), - ); - logger.append_ui_activity_records(&[second]).unwrap(); - let second_write = - std::fs::read_to_string(SessionLogger::ui_activity_log_path_for(&filename)) - .unwrap(); - assert_eq!(second_write.lines().count(), 2); - assert!(second_write.starts_with(&first_write)); - }); - }); - } - - #[test] - fn load_session_rebuilds_blocks_from_messages_when_ui_snapshot_is_stale() { - with_temp_lash_home("lash-session-load-rebuilds-blocks", || { - block_on(async { - let filename = new_session_filename(); - let path = sessions_dir().join(&filename); - let store = Arc::new(Store::open(&path).await.unwrap()); - SessionLogger::new( - Arc::clone(&store), - filename.clone(), - "gpt-test", - Some("s2".into()), - "demo".into(), - ) - .await - .unwrap(); - let assistant = "Line one\n\n1. first\n2. second\n"; - let messages = vec![ - text_message(MessageRole::User, "m0", "Hi"), - text_message(MessageRole::Assistant, "m1", assistant), - ]; - persist_root_snapshot(&store, messages, TokenUsage::default()).await; - - let loaded = load_session(&filename).await.unwrap(); - // blocks[0] = TurnStart, [1] = UserInput, [2] = AssistantText - assert!(matches!( - loaded.blocks.get(2), - Some(UiTimelineItem::AssistantText(text)) if text == assistant.trim() - )); - }); - }); - } - - #[test] - fn list_recent_sessions_ignores_child_sessions() { - with_temp_lash_home("lash-session-log-list", || { - block_on(async { - let parent = sessions_dir().join("parent.db"); - let child = sessions_dir().join("child.db"); - let parent_store = Arc::new(Store::open(&parent).await.unwrap()); - let child_store = Store::open(&child).await.unwrap(); - SessionLogger::new( - Arc::clone(&parent_store), - "parent.db".into(), - "gpt-test", - Some("parent".into()), - "demo".into(), - ) - .await - .unwrap(); - persist_root_snapshot( - &parent_store, - vec![text_message(MessageRole::User, "m0", "hello there")], - TokenUsage::default(), - ) - .await; - child_store - .save_session_meta(lash_core::SessionMeta { - session_id: "child".to_string(), - session_name: "demo".to_string(), - created_at: "2026-03-25T10:00:00Z".to_string(), - model: "gpt-test".to_string(), - cwd: std::env::current_dir() - .ok() - .and_then(|path| path.to_str().map(str::to_string)), - relation: lash_core::SessionRelation::Child { - parent_session_id: "parent".to_string(), - caused_by: None, - }, - }) - .await; - persist_root_snapshot( - &child_store, - vec![text_message(MessageRole::User, "m0", "child prompt")], - TokenUsage::default(), - ) - .await; - - let sessions = list_recent_sessions(10).await; - assert_eq!(sessions.len(), 1); - assert_eq!(sessions[0].filename, "parent.db"); - assert_eq!(sessions[0].message_count, 1); - assert_eq!(sessions[0].first_message, "hello there"); - }); - }); - } - - #[test] - fn session_identifier_resolves_filename_id_and_name_including_children() { - with_temp_lash_home("lash-session-identifier", || { - block_on(async { - let parent = sessions_dir().join("parent.db"); - let child = sessions_dir().join("child.db"); - let parent_store = Arc::new(Store::open(&parent).await.unwrap()); - let child_store = Store::open(&child).await.unwrap(); - SessionLogger::new( - Arc::clone(&parent_store), - "parent.db".into(), - "gpt-test", - Some("parent-id".into()), - "parent-name".into(), - ) - .await - .unwrap(); - child_store - .save_session_meta(lash_core::SessionMeta { - session_id: "child-id".to_string(), - session_name: "child-name".to_string(), - created_at: "2026-03-25T10:00:00Z".to_string(), - model: "gpt-test".to_string(), - cwd: None, - relation: lash_core::SessionRelation::Child { - parent_session_id: "parent-id".to_string(), - caused_by: None, - }, - }) - .await; - - assert_eq!( - filename_for_session_identifier("parent.db") - .await - .as_deref(), - Some("parent.db") - ); - assert_eq!( - filename_for_session_identifier("parent-id") - .await - .as_deref(), - Some("parent.db") - ); - assert_eq!( - filename_for_session_identifier("parent-name") - .await - .as_deref(), - Some("parent.db") - ); - assert_eq!( - filename_for_session_identifier("child-id").await.as_deref(), - Some("child.db") - ); - assert_eq!( - filename_for_session_identifier("child-name") - .await - .as_deref(), - Some("child.db") - ); +pub(crate) async fn incompatible_session_message(path: &Path) -> String { + let probe = lash_sqlite_store::SqliteStorePreflight::for_durable_core(path); + match lash::preflight::probe_store(&probe, lash::preflight::PreflightOptions::summary()).await { + Ok(report) => { + let stamps = report.refusal_message().unwrap_or_else(|| { + format!("{report}") + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join("; ") }); - }); - } - - #[test] - fn session_identifier_resolution_survives_sidecar_pollution() { - with_temp_lash_home("lash-session-sidecars", || { - block_on(async { - let parent = sessions_dir().join("parent.db"); - let parent_store = Arc::new(Store::open(&parent).await.unwrap()); - SessionLogger::new( - Arc::clone(&parent_store), - "parent.db".into(), - "gpt-test", - Some("parent-id".into()), - "parent-name".into(), - ) - .await - .unwrap(); - - // Component sidecars share the `.db` extension and, being - // written after the session store, sort first in the - // modified-time candidate order. An unreadable sidecar must - // not abort name/id resolution, and no sidecar may appear as - // a resumable session. - std::fs::write(sessions_dir().join("parent.db.effects.db"), b"not sqlite").unwrap(); - std::fs::write(sessions_dir().join("parent.db.processes.db"), b"junk").unwrap(); - - assert_eq!( - filename_for_session_identifier("parent-name") - .await - .as_deref(), - Some("parent.db") - ); - assert_eq!( - filename_for_session_identifier("parent-id") - .await - .as_deref(), - Some("parent.db") - ); - let sessions = list_recent_sessions(10).await; - assert_eq!(sessions.len(), 1); - assert_eq!(sessions[0].filename, "parent.db"); - }); - }); + format!( + "Session {} is from an older Lash and is not openable: {stamps}", + path.display() + ) + } + Err(error) => format!( + "Session {} is from an older Lash and is not openable: {error}", + path.display() + ), } } diff --git a/crates/lash-cli/src/startup/mod.rs b/crates/lash-cli/src/startup/mod.rs index 3119495..58c7c4a 100644 --- a/crates/lash-cli/src/startup/mod.rs +++ b/crates/lash-cli/src/startup/mod.rs @@ -7,8 +7,7 @@ //! when no stored/shortcut credential exists) — [`provider`] //! 3. resolve the startup model against the models.dev catalog //! 4. open the session bootstrap (store + sidecars) exactly once -//! 5. resolve execution mode / context approach from flags + persisted host -//! config +//! 5. resolve execution mode / RLM dialect from flags + persisted host config //! 6. assemble the plugin stack and prompt layer — [`plugins`] //! 7. build the `LashCore` and open/resume the session — [`session`] //! 8. hand off to the autonomous runner or the interactive TUI @@ -19,9 +18,6 @@ mod preflight; mod provider; pub(crate) mod session; -#[cfg(all(test, feature = "test-provider"))] -pub(crate) use plugins::cli_prompt_config; - use std::collections::BTreeMap; use std::sync::Arc; @@ -38,8 +34,8 @@ use serde_json::Value as JsonValue; use crate::autonomous::{AutonomousMode, AutonomousPersistenceContext, run_autonomous}; use crate::config::LashConfig; use crate::execution_settings::{ - ExecutionMode, OmOverrides, RlmTerminationMode, default_rlm_termination_for_mode, - ensure_supported_execution_mode, parse_execution_mode, parse_standard_context_approach, + ExecutionMode, RlmDialect, RlmTerminationMode, default_rlm_termination_for_mode, + ensure_supported_execution_mode, parse_execution_mode, }; use crate::info::{info_text, info_text_unconfigured}; use crate::instructions::{FsInstructionSource, InstructionLoaderConfig}; @@ -235,9 +231,19 @@ pub(crate) async fn run(args: Args) -> anyhow::Result<()> { if preflight::handle_early_exit_flags(&args).await? { return Ok(()); } + let store_preflight = preflight::probe_runtime_store().await?; // ── Stage 1: resolve config + provider credentials ── let config_path = crate::paths::config_file(); + let config_declares_rlm_dialect = std::fs::read(&config_path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|value| { + value + .as_object() + .map(|object| object.contains_key("rlm_dialect")) + }) + .unwrap_or(false); let config_load = LashConfig::load_outcome(&config_path); let existing_config = config_load.loaded().cloned(); let shortcut_api_key = provider::shortcut_api_key(&args); @@ -255,6 +261,7 @@ pub(crate) async fn run(args: Args) -> anyhow::Result<()> { if let Some(status) = config_load.status_line() { println!("{status}"); } + println!("preflight: {}", store_preflight.outcome.name()); println!("{}", info_text_unconfigured(&execution_mode, &cwd)); return Ok(()); } @@ -382,7 +389,7 @@ pub(crate) async fn run(args: Args) -> anyhow::Result<()> { .as_ref() .and_then(|bootstrap| bootstrap.persisted_host_config()); - // ── Stage 4: execution mode + context approach. CLI flag wins, then + // ── Stage 4: execution mode + dialect. CLI flag wins, then // persisted host config, then Standard. Resolved BEFORE building the // plugin host + runtime so the lashlang thread starts correctly and // plugins see the right mode on resume. ── @@ -394,29 +401,40 @@ pub(crate) async fn run(args: Args) -> anyhow::Result<()> { None => persisted_host_config .as_ref() .map(|config| config.execution_mode) - .and_then(|mode| ensure_supported_execution_mode(mode).ok()) - .unwrap_or(ExecutionMode::Standard), + .unwrap_or(lash_config.execution_mode), }; - let om_overrides = OmOverrides::from_args(&args); - if !execution_mode.is_standard() - && (args.standard_context_approach.is_some() || !om_overrides.is_empty()) + if args.rlm_dialect.is_some() && execution_mode.is_standard() { + return Err(anyhow::anyhow!( + "`--rlm-dialect` requires `--execution-mode rlm`." + )); + } + if let (Some(requested), Some(recorded)) = ( + args.rlm_dialect, + persisted_host_config + .as_ref() + .and_then(|config| config.rlm_dialect), + ) && requested != recorded { return Err(anyhow::anyhow!( - "`--context-approach` and OM tuning flags only apply to `--execution-mode standard`." + "RLM dialect conflict: session records `{}`, requested `{}`", + recorded.language_id(), + requested.language_id() )); } - let configured_standard_context_approach = if execution_mode.is_standard() { - let approach = match args.standard_context_approach.as_deref() { - Some(raw) => parse_standard_context_approach(raw).map_err(anyhow::Error::msg)?, - None => persisted_host_config - .as_ref() - .and_then(|config| config.standard_context_approach.clone()) - .unwrap_or_default(), - }; - Some(om_overrides.apply(approach).map_err(anyhow::Error::msg)?) - } else { - None - }; + let ambient_rlm_dialect = RlmDialect::from_env().map_err(anyhow::Error::msg)?; + let configured_rlm_dialect = config_declares_rlm_dialect + .then_some(lash_config.rlm_dialect) + .or(ambient_rlm_dialect) + .unwrap_or_default(); + let rlm_dialect = execution_mode.is_rlm().then_some( + args.rlm_dialect + .or_else(|| { + persisted_host_config + .as_ref() + .and_then(|config| config.rlm_dialect) + }) + .unwrap_or(configured_rlm_dialect), + ); let rlm_projected_bindings = resolve_rlm_projected_bindings(&args).map_err(anyhow::Error::msg)?; if rlm_projected_bindings.is_some() && !execution_mode.is_rlm() { @@ -427,11 +445,8 @@ pub(crate) async fn run(args: Args) -> anyhow::Result<()> { let rlm_termination = resolve_rlm_termination(&args, execution_mode, persisted_host_config.as_ref()) .map_err(anyhow::Error::msg)?; - let resolved_host_config = session::CliSessionHostConfig::new( - execution_mode, - configured_standard_context_approach.clone(), - rlm_termination, - ); + let resolved_host_config = + session::CliSessionHostConfig::new(execution_mode, rlm_dialect, rlm_termination); // ── Stage 5: plugin stack + prompt layer + session policy ── let instruction_source: Arc = @@ -440,14 +455,13 @@ pub(crate) async fn run(args: Args) -> anyhow::Result<()> { ..Default::default() })); let prompt_layer = plugins::cli_prompt_config(autonomous, &execution_mode); - let session_policy = SessionPolicy { - model: startup_model.model_spec.clone(), - provider_id: active_provider.kind().to_string(), - session_id: run_session_id.clone(), - autonomous, - prompt: prompt_layer.clone(), - ..Default::default() - }; + let mut session_policy = SessionPolicy::new(crate::host_policy::turn_budget()); + session_policy.model = startup_model.model_spec.clone(); + session_policy.provider_id = active_provider.kind().to_string(); + session_policy.session_id = run_session_id.clone(); + session_policy.autonomous = autonomous; + session_policy.no_progress_budget = crate::host_policy::no_progress_budget(); + session_policy.prompt = prompt_layer.clone(); let tavily_key = lash_config.tavily_api_key().unwrap_or_default().to_string(); let prompt_bridge = CliPromptBridge::default(); @@ -455,7 +469,6 @@ pub(crate) async fn run(args: Args) -> anyhow::Result<()> { plugins::plugin_factories_for_surface(plugins::PluginFactorySurfaceInput { autonomous, execution_mode, - standard_context_approach: configured_standard_context_approach.clone(), tavily_key, instruction_source: Arc::clone(&instruction_source), agent_model_specs: &startup_model.agent_model_specs, @@ -474,18 +487,18 @@ pub(crate) async fn run(args: Args) -> anyhow::Result<()> { // demand. Built from the MCP pool's currently enumerated tools. let mcp_cataloged_tools = { let provider = McpToolProvider::new(Arc::clone(mcp_factory.pool())); - crate::examples::mcp_discovery::mcp_cataloged_tools("mcp", &provider) + crate::mcp_discovery::mcp_cataloged_tools("mcp", &provider) }; let has_deferred_mcp_catalog = execution_mode.is_rlm() && !mcp_cataloged_tools.is_empty(); let mcp_catalog_records = if has_deferred_mcp_catalog { - crate::examples::mcp_discovery::mcp_catalog_records(&mcp_cataloged_tools) + crate::mcp_discovery::mcp_catalog_records(&mcp_cataloged_tools) } else { Default::default() }; let mcp_deferred_resolver: Option = if has_deferred_mcp_catalog { Some(Arc::new( - crate::examples::mcp_discovery::McpDeferredToolResolver::new(mcp_cataloged_tools), + crate::mcp_discovery::McpDeferredToolResolver::new(mcp_cataloged_tools), )) } else { None @@ -496,9 +509,7 @@ pub(crate) async fn run(args: Args) -> anyhow::Result<()> { if execution_mode.is_rlm() { if has_deferred_mcp_catalog { plugin_stack.push(Arc::new( - crate::examples::mcp_discovery::ToolDiscoveryPluginFactory::with_catalog( - mcp_catalog_records, - ), + crate::mcp_discovery::ToolDiscoveryPluginFactory::with_catalog(mcp_catalog_records), )); plugin_stack.push(Arc::new(StaticPluginFactory::new( "mcp", @@ -514,6 +525,7 @@ pub(crate) async fn run(args: Args) -> anyhow::Result<()> { let cwd = std::env::current_dir() .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|_| ".".to_string()); + println!("preflight: {}", store_preflight.outcome.name()); println!( "{}", info_text( @@ -521,7 +533,7 @@ pub(crate) async fn run(args: Args) -> anyhow::Result<()> { &startup_model.model, session_policy.model.variant.effort(), &execution_mode, - configured_standard_context_approach.as_ref(), + rlm_dialect, Some(startup_model.resolved_spec.context_window()), None, &cwd, @@ -562,17 +574,23 @@ pub(crate) async fn run(args: Args) -> anyhow::Result<()> { // background so it never delays an interactive session. { let attachments_dir = crate::paths::attachments_dir(); - let sessions_dir = crate::session_log::sessions_dir(); + let store_dir = crate::paths::store_dir(); tokio::spawn(async move { // One hour: far larger than any live turn, so an in-flight put whose // intent has not yet been observed is never swept. const ATTACHMENT_GC_GRACE_MS: u64 = 60 * 60 * 1000; let backend = FileAttachmentStore::new(attachments_dir); - let root_set = lash_sqlite_store::SqliteSessionStoreFactory::new(sessions_dir); + let root_set = lash_sqlite_store::SqliteSessionStoreFactory::new_with_process_registry( + store_dir, + crate::paths::processes_db(), + ); match lash::persistence::reclaim_unreferenced_attachments( &root_set, &backend, - ATTACHMENT_GC_GRACE_MS, + lash::persistence::AttachmentReclamationPolicy { + grace_period_ms: ATTACHMENT_GC_GRACE_MS, + empty_root_set: lash::persistence::EmptyRootSetPolicy::Refuse, + }, ) .await { @@ -603,7 +621,6 @@ pub(crate) async fn run(args: Args) -> anyhow::Result<()> { hash12(&serde_json::to_vec(&active_tool_definitions).unwrap_or_else(|_| b"[]".to_vec())); let initial_policy = session.policy_snapshot(); let initial_model_variant = initial_policy.model.variant.clone(); - let store = opened_session.bootstrap.store(); let session_name = opened_session.bootstrap.session_name(); let mut logger = opened_session.logger; session @@ -624,7 +641,7 @@ pub(crate) async fn run(args: Args) -> anyhow::Result<()> { crate::CliMode::Json => AutonomousMode::Json, crate::CliMode::Rpc => AutonomousMode::Rpc, }; - return run_autonomous( + let result = run_autonomous( session, prompt, SkillCatalog::from_dirs(&crate::paths::default_skill_dirs()), @@ -634,11 +651,15 @@ pub(crate) async fn run(args: Args) -> anyhow::Result<()> { }, rlm_projected_bindings, mode, + runtime_factory.active_effect_host(), ) .await; + let shutdown = runtime_factory.shutdown().await; + result?; + return shutdown; } if args.mode == crate::CliMode::Rpc { - return run_autonomous( + let result = run_autonomous( session, String::new(), SkillCatalog::from_dirs(&crate::paths::default_skill_dirs()), @@ -648,8 +669,12 @@ pub(crate) async fn run(args: Args) -> anyhow::Result<()> { }, None, AutonomousMode::Rpc, + runtime_factory.active_effect_host(), ) .await; + let shutdown = runtime_factory.shutdown().await; + result?; + return shutdown; } // ── Stage 7b: interactive TUI ── @@ -674,7 +699,8 @@ pub(crate) async fn run(args: Args) -> anyhow::Result<()> { crate::util::require_interactive_terminal("the lash TUI")?; let terminal = Terminal::enter()?; - run_app( + let shutdown_factory = runtime_factory.clone(); + let result = run_app( terminal, session, runtime_factory, @@ -687,13 +713,16 @@ pub(crate) async fn run(args: Args) -> anyhow::Result<()> { startup_model.resolved_spec.context_window(), session_name, model_catalog, - Arc::clone(&store), toolset_hash, crate::model_selection::variant_from_reasoning_selection(initial_model_variant), execution_mode, + rlm_dialect, startup_system_message, ) - .await + .await; + let shutdown = shutdown_factory.shutdown().await; + result?; + shutdown } fn should_restore_terminal_for_panic(terminal_owner_thread: std::thread::ThreadId) -> bool { diff --git a/crates/lash-cli/src/startup/onboarding.rs b/crates/lash-cli/src/startup/onboarding.rs index 5cb771a..222a1da 100644 --- a/crates/lash-cli/src/startup/onboarding.rs +++ b/crates/lash-cli/src/startup/onboarding.rs @@ -1,13 +1,13 @@ use std::collections::BTreeSet; use std::time::Duration; -use crate::config::LashConfig; +use crate::config::{LashConfig, ProviderConfig}; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; -use lash_core::provider::ProviderHandle; +use lash::provider::ProviderHandle; use lash_provider_anthropic::AnthropicProvider; use lash_provider_auth as oauth; -use lash_provider_google::GoogleOAuthProvider; use lash_provider_google::oauth as google_oauth; +use lash_provider_google::{GoogleOAuthClient, GoogleOAuthProvider}; use lash_provider_openai::codex::oauth as codex_oauth; use lash_provider_openai::{CodexProvider, OpenAiCompatibleProvider, OpenAiProvider}; use lash_tui::{Frame, Line, Modifier, Rect, Span, Style, Terminal}; @@ -51,6 +51,12 @@ enum SetupStep { input: String, cursor: usize, }, + SelectExecutionMode { + selected: usize, + }, + SelectRlmDialect { + selected: usize, + }, Done, } @@ -99,8 +105,15 @@ async fn run_setup_inner( ) -> anyhow::Result { let existing_config = existing.cloned(); let mut app = SetupApp::new(existing_config.as_ref()); - let mut provider: Option = None; + let mut provider: Option<(ProviderConfig, ProviderHandle)> = None; let mut tavily_key = existing.and_then(|cfg| cfg.tavily_api_key().map(str::to_string)); + let mut selected_execution_mode = existing + .map(|config| config.execution_mode) + .unwrap_or_default(); + let mut selected_rlm_dialect = existing + .map(|config| config.rlm_dialect) + .unwrap_or_default(); + let default_execution_mode = selected_execution_mode; let existing_agent_models = existing .map(|cfg| cfg.agent_models.clone()) .unwrap_or_default(); @@ -173,23 +186,18 @@ async fn run_setup_inner( Ok(Some((auth_code, code_verifier))) => { match codex_oauth::exchange_code(&auth_code, &code_verifier).await { Ok(tokens) => { - provider = Some(ProviderHandle::new( - CodexProvider::new( - tokens.access_token, - tokens.refresh_token, - tokens.expires_at, - ) - .with_account_id(tokens.account_id) - .into_components(), + let concrete = CodexProvider::new( + tokens.access_token, + tokens.refresh_token, + tokens.expires_at, + ) + .with_account_id(tokens.account_id); + provider = Some(( + ProviderConfig::from_provider(&concrete), + ProviderHandle::new(concrete.into_components()), )); - app.step = if tavily_key.is_some() { - SetupStep::Done - } else { - SetupStep::InputTavily { - input: String::new(), - cursor: 0, - } - }; + app.step = + next_setup_step(tavily_key.is_some(), default_execution_mode); } Err(err) => { if let SetupStep::CodexDeviceAuth { error, .. } = &mut app.step { @@ -253,21 +261,15 @@ async fn run_setup_inner( let kind = crate::provider_metadata::PROVIDER_SETUP_ORDER[*selected]; if let Some(saved_spec) = existing_config .as_ref() - .and_then(|cfg| cfg.provider_spec(kind)) + .and_then(|cfg| cfg.provider_config(kind)) .cloned() { - match crate::config::materialize_provider_spec(&saved_spec) { + match crate::config::materialize_provider(&saved_spec) { Ok(handle) => { - provider = Some(handle); + provider = Some((saved_spec, handle)); app.active_kind = Some(kind.to_string()); - app.step = if tavily_key.is_some() { - SetupStep::Done - } else { - SetupStep::InputTavily { - input: String::new(), - cursor: 0, - } - }; + app.step = + next_setup_step(tavily_key.is_some(), default_execution_mode); } Err(_) => { start_provider_flow(&mut app, kind, existing_config.as_ref()).await; @@ -306,34 +308,26 @@ async fn run_setup_inner( *error = Some("API key cannot be empty.".into()); continue; } - provider = Some(ProviderHandle::new( - AnthropicProvider::new(value).into_components(), + let concrete = AnthropicProvider::new(value); + provider = Some(( + ProviderConfig::from_provider(&concrete), + ProviderHandle::new(concrete.into_components()), )); - app.step = if tavily_key.is_some() { - SetupStep::Done - } else { - SetupStep::InputTavily { - input: String::new(), - cursor: 0, - } - }; + app.step = + next_setup_step(tavily_key.is_some(), default_execution_mode); } CredentialMode::OpenAiKey => { if value.is_empty() { *error = Some("API key cannot be empty.".into()); continue; } - provider = Some(ProviderHandle::new( - OpenAiProvider::new(value).into_components(), + let concrete = OpenAiProvider::new(value); + provider = Some(( + ProviderConfig::from_provider(&concrete), + ProviderHandle::new(concrete.into_components()), )); - app.step = if tavily_key.is_some() { - SetupStep::Done - } else { - SetupStep::InputTavily { - input: String::new(), - cursor: 0, - } - }; + app.step = + next_setup_step(tavily_key.is_some(), default_execution_mode); } CredentialMode::OpenAiCompatibleKey => { if value.is_empty() { @@ -355,33 +349,52 @@ async fn run_setup_inner( *error = Some("OAuth state expired. Press Esc and retry.".into()); continue; }; - match google_oauth::exchange_code(&value, &verifier_value).await { + let oauth_client = match google_oauth_client_from_env() { + Ok(client) => client, + Err(message) => { + *error = Some(message); + continue; + } + }; + match google_oauth::exchange_code( + &oauth_client, + &value, + &verifier_value, + ) + .await + { Ok(tokens) => { let project_id = std::env::var("GOOGLE_CLOUD_PROJECT") .ok() .or_else(|| std::env::var("GOOGLE_CLOUD_PROJECT_ID").ok()); - provider = Some(ProviderHandle::new( - GoogleOAuthProvider::new( - tokens.access_token, - tokens.refresh_token, - tokens.expires_at, - ) - .with_project_id(project_id) - .into_components(), + let concrete = GoogleOAuthProvider::new( + tokens.access_token, + tokens.refresh_token, + tokens.expires_at, + oauth_client, + ) + .with_project_id(project_id); + provider = Some(( + ProviderConfig::from_provider(&concrete), + ProviderHandle::new(concrete.into_components()), )); - app.step = if tavily_key.is_some() { - SetupStep::Done - } else { - SetupStep::InputTavily { - input: String::new(), - cursor: 0, - } - }; + app.step = next_setup_step( + tavily_key.is_some(), + default_execution_mode, + ); } Err(err) => { *error = Some(err.to_string()); let (new_verifier, challenge) = oauth::generate_pkce(); - match google_oauth::authorize_url(&challenge) { + let oauth_client = match google_oauth_client_from_env() { + Ok(client) => client, + Err(auth_err) => { + *error = Some(auth_err); + continue; + } + }; + match google_oauth::authorize_url(&oauth_client.id, &challenge) + { Ok(url) => { persist_oauth_url(&url); *browser_error = @@ -424,17 +437,12 @@ async fn run_setup_inner( if base_url.is_empty() { continue; } - provider = Some(ProviderHandle::new( - OpenAiCompatibleProvider::new(api_key.clone(), base_url).into_components(), + let concrete = OpenAiCompatibleProvider::new(api_key.clone(), base_url); + provider = Some(( + ProviderConfig::from_provider(&concrete), + ProviderHandle::new(concrete.into_components()), )); - app.step = if tavily_key.is_some() { - SetupStep::Done - } else { - SetupStep::InputTavily { - input: String::new(), - cursor: 0, - } - }; + app.step = next_setup_step(tavily_key.is_some(), default_execution_mode); } KeyCode::Char(ch) => { input.insert(*cursor, ch); @@ -445,7 +453,7 @@ async fn run_setup_inner( SetupStep::InputTavily { input, cursor } => match key.code { KeyCode::Esc => { tavily_key = None; - app.step = SetupStep::Done; + app.step = execution_mode_step(default_execution_mode); } KeyCode::Backspace => delete_left(input, cursor), KeyCode::Left => move_left(input, cursor), @@ -453,7 +461,7 @@ async fn run_setup_inner( KeyCode::Enter => { let value = input.trim().to_string(); tavily_key = if value.is_empty() { None } else { Some(value) }; - app.step = SetupStep::Done; + app.step = execution_mode_step(default_execution_mode); } KeyCode::Char(ch) => { input.insert(*cursor, ch); @@ -461,21 +469,85 @@ async fn run_setup_inner( } _ => {} }, + SetupStep::SelectExecutionMode { selected } => match key.code { + KeyCode::Up | KeyCode::Char('k') => *selected = selected.saturating_sub(1), + KeyCode::Down | KeyCode::Char('j') => *selected = (*selected + 1).min(1), + KeyCode::Enter => { + selected_execution_mode = if *selected == 0 { + crate::execution_settings::ExecutionMode::Standard + } else { + crate::execution_settings::ExecutionMode::Rlm + }; + app.step = if selected_execution_mode.is_rlm() { + dialect_step(selected_rlm_dialect) + } else { + SetupStep::Done + }; + } + _ => {} + }, + SetupStep::SelectRlmDialect { selected } => match key.code { + KeyCode::Up | KeyCode::Char('k') => *selected = selected.saturating_sub(1), + KeyCode::Down | KeyCode::Char('j') => { + *selected = (*selected + 1).min( + crate::execution_settings::RlmDialect::ALL + .len() + .saturating_sub(1), + ); + } + KeyCode::Enter => { + selected_rlm_dialect = crate::execution_settings::RlmDialect::ALL[*selected]; + app.step = SetupStep::Done; + } + KeyCode::Esc => app.step = execution_mode_step(selected_execution_mode), + _ => {} + }, SetupStep::CodexDeviceAuth { .. } | SetupStep::Done => {} } } - let provider = provider.expect("provider must be selected before setup finishes"); - let mut config = existing_config.unwrap_or_else(|| LashConfig::new(&provider)); - config.upsert_provider(&provider); + let (provider_config, provider) = + provider.expect("provider must be selected before setup finishes"); + let mut config = existing_config.unwrap_or_else(|| LashConfig::new(provider_config.clone())); + config.upsert_provider(provider_config); config .set_active_provider_kind(provider.kind()) .expect("active provider must exist after upsert"); config.set_tavily_api_key(tavily_key); + config.execution_mode = selected_execution_mode; + config.rlm_dialect = selected_rlm_dialect; config.agent_models = existing_agent_models; Ok(config) } +fn execution_mode_step(mode: crate::execution_settings::ExecutionMode) -> SetupStep { + SetupStep::SelectExecutionMode { + selected: usize::from(mode.is_rlm()), + } +} + +fn dialect_step(dialect: crate::execution_settings::RlmDialect) -> SetupStep { + let selected = crate::execution_settings::RlmDialect::ALL + .iter() + .position(|candidate| *candidate == dialect) + .unwrap_or_default(); + SetupStep::SelectRlmDialect { selected } +} + +fn next_setup_step( + tavily_is_configured: bool, + execution_mode: crate::execution_settings::ExecutionMode, +) -> SetupStep { + if tavily_is_configured { + execution_mode_step(execution_mode) + } else { + SetupStep::InputTavily { + input: String::new(), + cursor: 0, + } + } +} + async fn start_provider_flow(app: &mut SetupApp, kind: &str, existing: Option<&LashConfig>) { match kind { "codex" => match codex_oauth::request_device_code().await { @@ -501,7 +573,22 @@ async fn start_provider_flow(app: &mut SetupApp, kind: &str, existing: Option<&L }, "google_oauth" => { let (verifier, challenge) = oauth::generate_pkce(); - match google_oauth::authorize_url(&challenge) { + let oauth_client = match google_oauth_client_from_env() { + Ok(client) => client, + Err(error) => { + app.step = SetupStep::InputCredential { + input: String::new(), + cursor: 0, + error: Some(error.to_string()), + verifier: None, + auth_url: None, + browser_error: None, + mode: CredentialMode::GoogleOAuth, + }; + return; + } + }; + match google_oauth::authorize_url(&oauth_client.id, &challenge) { Ok(url) => { persist_oauth_url(&url); let browser_error = open_browser(&url).err().map(|err| err.to_string()); @@ -805,6 +892,66 @@ fn draw_setup(frame: &mut Frame<'_>, app: &SetupApp) { }, ); } + SetupStep::SelectExecutionMode { selected } => { + draw_panel_title(frame, panel, "Execution Mode"); + let choices = [ + ("Standard", "Use the standard tool-calling protocol."), + ("RLM", "Run model-authored programs in a pinned language."), + ]; + let mut lines = vec![ + Line::from("Choose the default for newly created sessions."), + Line::from(""), + ]; + for (index, (label, description)) in choices.iter().enumerate() { + let marker = if *selected == index { "▸" } else { " " }; + lines.push(Line::from(vec![ + Span::styled( + format!("{marker} {label:<12}"), + if *selected == index { + Style::default() + .fg(theme::text_primary()) + .add_modifier(Modifier::Bold) + } else { + Style::default().fg(theme::text_subtle()) + }, + ), + Span::styled(*description, Style::default().fg(theme::text_faint())), + ])); + } + lines.push(Line::from("")); + lines.push(help_line(&[("↑↓", "move"), ("enter", "select")])); + draw_lines(frame, inner_panel(panel), &lines); + } + SetupStep::SelectRlmDialect { selected } => { + draw_panel_title(frame, panel, "RLM Dialect"); + let mut lines = vec![ + Line::from("Choose the language pinned to each new RLM session."), + Line::from(""), + ]; + for (index, dialect) in crate::execution_settings::RlmDialect::ALL + .iter() + .enumerate() + { + let marker = if *selected == index { "▸" } else { " " }; + lines.push(Line::from(Span::styled( + format!("{marker} {}", dialect.language_id()), + if *selected == index { + Style::default() + .fg(theme::text_primary()) + .add_modifier(Modifier::Bold) + } else { + Style::default().fg(theme::text_subtle()) + }, + ))); + } + lines.push(Line::from("")); + lines.push(help_line(&[ + ("↑↓", "move"), + ("enter", "select"), + ("esc", "back"), + ])); + draw_lines(frame, inner_panel(panel), &lines); + } SetupStep::Done => { draw_panel_title(frame, panel, "Ready"); let lines = vec![ @@ -1119,7 +1266,7 @@ fn visible_start(input: &str, width: usize, cursor_byte: usize) -> usize { fn provider_spec_field(existing: Option<&LashConfig>, kind: &str, field: &str) -> String { existing - .and_then(|cfg| cfg.provider_spec(kind)) + .and_then(|cfg| cfg.provider_config(kind)) .and_then(|spec| { spec.config .get(field) @@ -1129,6 +1276,19 @@ fn provider_spec_field(existing: Option<&LashConfig>, kind: &str, field: &str) - .unwrap_or_default() } +fn google_oauth_client_from_env() -> Result { + let id = std::env::var("LASH_GOOGLE_CLIENT_ID").ok(); + let secret = std::env::var("LASH_GOOGLE_CLIENT_SECRET").ok(); + match (id, secret) { + (Some(id), Some(secret)) if !id.trim().is_empty() && !secret.trim().is_empty() => { + Ok(GoogleOAuthClient { id, secret }) + } + _ => Err( + "Google setup requires LASH_GOOGLE_CLIENT_ID and LASH_GOOGLE_CLIENT_SECRET".to_string(), + ), + } +} + fn existing_openai_base_url(existing: Option<&LashConfig>) -> String { provider_spec_field(existing, "openai-compatible", "base_url") } diff --git a/crates/lash-cli/src/startup/plugins.rs b/crates/lash-cli/src/startup/plugins.rs index 5a9086c..3e8eb1f 100644 --- a/crates/lash-cli/src/startup/plugins.rs +++ b/crates/lash-cli/src/startup/plugins.rs @@ -11,9 +11,8 @@ use lash::prompt::{ }; use lash::{ModelSpec, PluginStack, SessionSpec}; use lash_core::plugin::{PluginSpec, StaticPluginFactory}; -use lash_core::{PromptLayer, SessionToolAccess, ToolProvider, ToolState}; +use lash_core::{PromptLayer, SessionToolAccess, ToolProvider}; use lash_llm_tools::LlmToolsPluginFactory; -use lash_plugin_plan_mode::{PlanModePluginFactory, UpdatePlanPluginFactory}; use lash_search_tools::grep_provider; use lash_standard_plugins::{ StandardContextApproach, StandardToolStackOptions, standard_tool_stack, @@ -32,7 +31,6 @@ const CLI_AUTONOMOUS_EXECUTION: &str = "- No user is available during this run. pub(super) struct PluginFactorySurfaceInput<'a> { pub(super) autonomous: bool, pub(super) execution_mode: ExecutionMode, - pub(super) standard_context_approach: Option, pub(super) tavily_key: String, pub(super) instruction_source: Arc, pub(super) agent_model_specs: &'a BTreeMap, @@ -44,7 +42,6 @@ pub(super) fn plugin_factories_for_surface(input: PluginFactorySurfaceInput<'_>) let PluginFactorySurfaceInput { autonomous, execution_mode, - standard_context_approach, tavily_key, instruction_source, agent_model_specs, @@ -54,7 +51,9 @@ pub(super) fn plugin_factories_for_surface(input: PluginFactorySurfaceInput<'_>) let capability_registry = Arc::new(default_registry(agent_model_specs)); let runtime_options = StandardToolStackOptions { - standard_context_approach: standard_context_approach.clone(), + standard_context_approach: execution_mode + .is_standard() + .then(|| StandardContextApproach::RollingHistory(Default::default())), include_cancel_process: execution_mode.is_standard(), tavily_api_key: if tavily_key.is_empty() { None @@ -76,16 +75,12 @@ pub(super) fn plugin_factories_for_surface(input: PluginFactorySurfaceInput<'_>) as Arc, ); } - plugin_stack.push(Arc::new( - PlanModePluginFactory::new(Default::default()) - .with_prompt(Arc::new(prompt_bridge.clone())), - )); plugin_stack.push(cli_ask_plugin_factory(prompt_bridge)); // `update_plan` drives the sticky plan dock at the bottom of // the TUI. Interactive-only here; root-only inside the plugin // itself (the factory returns an inert plugin for subagent // / compaction / other non-root sessions). - plugin_stack.push(Arc::new(UpdatePlanPluginFactory)); + plugin_stack.push(Arc::new(crate::plan_plugin::UpdatePlanPluginFactory)); } plugin_stack.push(Arc::new(lash_autoresearch::AutoresearchPluginFactory)); if execution_mode.is_rlm() { @@ -135,29 +130,18 @@ fn autonomous_tool_allowed(name: &str) -> bool { pub(super) async fn apply_autonomous_tool_policy( session: &lash::LashSession, ) -> anyhow::Result<()> { - let mut snapshot = session.admin().tools().state().await?; - apply_autonomous_tool_membership(&mut snapshot); - session - .admin() - .tools() - .advanced() - .apply_state(snapshot) - .await?; - Ok(()) -} - -fn apply_autonomous_tool_membership(snapshot: &mut ToolState) { - let disallowed = snapshot - .iter() - .filter(|(_, entry)| !autonomous_tool_allowed(&entry.manifest().name)) - .map(|(id, _)| id.clone()) + let tools = session.admin().tools(); + let updates = tools + .active_manifests() + .await? + .into_iter() + .filter(|manifest| !autonomous_tool_allowed(&manifest.name)) + .map(|manifest| (manifest.id, false)) .collect::>(); - - for id in disallowed { - snapshot - .set_membership(&id, false) - .expect("tool id collected from the same snapshot"); + if !updates.is_empty() { + tools.set_membership_many(&updates).await?; } + Ok(()) } pub(crate) fn cli_prompt_config(autonomous: bool, _execution_mode: &ExecutionMode) -> PromptLayer { @@ -207,61 +191,12 @@ pub(crate) fn cli_prompt_config(autonomous: bool, _execution_mode: &ExecutionMod mod tests { use super::*; use crate::instructions::FsInstructionSource; - use lash::tools::{ - ToolCall, ToolContract, ToolDefinition, ToolManifest, ToolProvider, ToolResult, - ToolScheduling, - }; - use lash_core::ToolRegistry; - - struct CatalogFixtureProvider; - - fn fixture_tool(name: &str) -> ToolDefinition { - ToolDefinition::raw( - format!("tool:{name}"), - name, - format!("{name} description"), - ToolDefinition::default_input_schema(), - serde_json::json!({ "type": "null" }), - ) - .with_scheduling(ToolScheduling::Parallel) - } - - #[async_trait::async_trait] - impl ToolProvider for CatalogFixtureProvider { - fn tool_manifests(&self) -> Vec { - fixture_tools() - .into_iter() - .map(|tool| tool.manifest()) - .collect() - } - - fn resolve_contract(&self, name: &str) -> Option> { - fixture_tools() - .into_iter() - .find(|tool| tool.name() == name) - .map(|tool| std::sync::Arc::new(tool.contract())) - } - - async fn execute(&self, call: ToolCall<'_>) -> ToolResult { - ToolResult::err_fmt(format_args!("unexpected tool call: {}", call.name)) - } - } - - fn fixture_tools() -> Vec { - vec![ - fixture_tool("read_file"), - fixture_tool("ask"), - fixture_tool("plan_exit"), - fixture_tool("showcase"), - ] - } fn plugin_factory_ids_for_autonomous(autonomous: bool) -> Vec<&'static str> { let agent_model_specs = BTreeMap::new(); plugin_factories_for_surface(PluginFactorySurfaceInput { autonomous, execution_mode: ExecutionMode::Standard, - standard_context_approach: None, tavily_key: String::new(), instruction_source: Arc::new(FsInstructionSource::default()), agent_model_specs: &agent_model_specs, @@ -387,20 +322,10 @@ mod tests { #[test] fn autonomous_policy_disables_interactive_tools() { - let tool_registry = - ToolRegistry::from_tool_provider(Arc::new(CatalogFixtureProvider)).unwrap(); - - let mut snapshot = tool_registry.export_state(); - apply_autonomous_tool_membership(&mut snapshot); - let is_member = |id| { - snapshot - .get(&lash_core::ToolId::from(id)) - .is_some_and(lash_core::ToolStateEntry::is_member) - }; - assert!(is_member("tool:read_file")); - assert!(!is_member("tool:ask")); - assert!(!is_member("tool:plan_exit")); - assert!(!is_member("tool:showcase")); + assert!(autonomous_tool_allowed("read_file")); + assert!(!autonomous_tool_allowed("ask")); + assert!(!autonomous_tool_allowed("plan_exit")); + assert!(!autonomous_tool_allowed("showcase")); } #[test] diff --git a/crates/lash-cli/src/startup/preflight.rs b/crates/lash-cli/src/startup/preflight.rs index d6e9197..19069f4 100644 --- a/crates/lash-cli/src/startup/preflight.rs +++ b/crates/lash-cli/src/startup/preflight.rs @@ -3,6 +3,20 @@ use crate::Args; +pub(super) async fn probe_runtime_store() -> anyhow::Result { + let handle = + lash_sqlite_store::SqliteStorePreflight::for_durable_core(crate::paths::durable_core_db()) + .with_process_registry(crate::paths::processes_db()) + .with_trigger_store(crate::paths::triggers_db()) + .with_effect_journal(crate::paths::effects_db()); + let report = + lash::preflight::probe_store(&handle, lash::preflight::PreflightOptions::summary()).await?; + if let Some(message) = report.refusal_message() { + anyhow::bail!(message); + } + Ok(report) +} + /// Handle flags that complete without starting a session. Returns `true` /// when the process should exit successfully without continuing startup. pub(super) async fn handle_early_exit_flags(args: &Args) -> anyhow::Result { @@ -24,8 +38,7 @@ pub(super) async fn handle_early_exit_flags(args: &Args) -> anyhow::Result Ok(false) } -/// `--reset`: confirm, then delete all lash data (config, credentials, -/// sessions, caches). +/// `--reset`: confirm, then delete the unified store and host session roster. fn run_reset() -> anyhow::Result<()> { use std::io::Write; @@ -38,21 +51,21 @@ fn run_reset() -> anyhow::Result<()> { const BOLD: &str = "\x1b[1m"; const RESET: &str = "\x1b[0m"; - let lash_dir = crate::paths::lash_home(); - let cache_dir = crate::paths::lash_cache_dir(); + let store_dir = crate::paths::store_dir(); + let sessions_dir = crate::session_log::sessions_dir(); eprintln!(); eprintln!(" {SODIUM}{BOLD}/ reset{RESET}"); eprintln!(); - eprintln!(" {ERR}This will permanently delete all lash data:{RESET}"); + eprintln!(" {ERR}This will permanently delete Lash runtime data:{RESET}"); eprintln!(); eprintln!( - " {ASH_TEXT}config, credentials {CHALK}{}{RESET}", - lash_dir.display() + " {ASH_TEXT}durable store {CHALK}{}{RESET}", + store_dir.display() ); eprintln!( - " {ASH_TEXT}runtime cache {CHALK}{}{RESET}", - cache_dir.display() + " {ASH_TEXT}session roster {CHALK}{}{RESET}", + sessions_dir.display() ); eprintln!(); eprint!(" {SODIUM}Are you sure? [y/N]{RESET} "); @@ -61,13 +74,13 @@ fn run_reset() -> anyhow::Result<()> { let mut answer = String::new(); std::io::stdin().read_line(&mut answer)?; if answer.trim().eq_ignore_ascii_case("y") { - if lash_dir.exists() { - std::fs::remove_dir_all(&lash_dir)?; + if store_dir.exists() { + std::fs::remove_dir_all(&store_dir)?; } - if cache_dir.exists() { - std::fs::remove_dir_all(&cache_dir)?; + if sessions_dir.exists() { + std::fs::remove_dir_all(&sessions_dir)?; } - eprintln!(" {LICHEN}Done.{RESET} All data removed."); + eprintln!(" {LICHEN}Done.{RESET} Runtime store and session roster removed."); } else { eprintln!(" {ASH_TEXT}Aborted.{RESET}"); } diff --git a/crates/lash-cli/src/startup/provider.rs b/crates/lash-cli/src/startup/provider.rs index 1ea4f23..27eb028 100644 --- a/crates/lash-cli/src/startup/provider.rs +++ b/crates/lash-cli/src/startup/provider.rs @@ -3,17 +3,30 @@ //! stored/shortcut credential (or `--provider` forces it). use crate::Args; -use crate::config::{ConfigLoadOutcome, LashConfig}; +use crate::config::{ConfigLoadOutcome, LashConfig, ProviderConfig}; use lash::provider::ProviderHandle; use lash_provider_openai::{OPENROUTER_BASE_URL, OpenAiCompatibleProvider, OpenAiProvider}; use super::onboarding; -pub(super) fn openai_shortcut_provider(api_key: String, base_url: &str) -> ProviderHandle { +#[cfg(test)] +fn openai_shortcut_provider(api_key: String, base_url: &str) -> ProviderHandle { + openai_shortcut(api_key, base_url).1 +} + +fn openai_shortcut(api_key: String, base_url: &str) -> (ProviderConfig, ProviderHandle) { if base_url.trim().is_empty() { - ProviderHandle::new(OpenAiProvider::new(api_key).into_components()) + let provider = OpenAiProvider::new(api_key); + ( + ProviderConfig::from_provider(&provider), + ProviderHandle::new(provider.into_components()), + ) } else { - ProviderHandle::new(OpenAiCompatibleProvider::new(api_key, base_url).into_components()) + let provider = OpenAiCompatibleProvider::new(api_key, base_url); + ( + ProviderConfig::from_provider(&provider), + ProviderHandle::new(provider.into_components()), + ) } } @@ -75,9 +88,10 @@ pub(super) async fn resolve_config_and_provider( let existing_config = load_outcome.loaded().cloned(); if args.provider || existing_config.is_none() { if let Some(key) = shortcut_api_key { - let provider = openai_shortcut_provider(key.to_string(), &args.base_url); - let mut cfg = existing_config.unwrap_or_else(|| LashConfig::new(&provider)); - cfg.upsert_provider(&provider); + let (provider_config, provider) = openai_shortcut(key.to_string(), &args.base_url); + let mut cfg = + existing_config.unwrap_or_else(|| LashConfig::new(provider_config.clone())); + cfg.upsert_provider(provider_config); let _ = cfg.set_active_provider_kind(provider.kind()); cfg.set_tavily_api_key(args.tavily_api_key.clone()); return Ok((cfg, provider)); @@ -139,14 +153,15 @@ mod tests { fn api_key_shortcut_selects_direct_openai_without_base_url() { let provider = openai_shortcut_provider("key".to_string(), ""); assert_eq!(provider.kind(), "openai"); - assert_eq!(provider.to_spec().kind, "openai"); - assert!(provider.to_spec().config.get("base_url").is_none()); + let config = openai_shortcut("key".to_string(), "").0; + assert_eq!(config.kind, "openai"); + assert!(config.config.get("base_url").is_none()); } #[test] fn api_key_shortcut_selects_compatible_provider_with_base_url() { let provider = openai_shortcut_provider("key".to_string(), "https://example.invalid/v1"); - let spec = provider.to_spec(); + let spec = openai_shortcut("key".to_string(), "https://example.invalid/v1").0; assert_eq!(provider.kind(), "openai-compatible"); assert_eq!(spec.kind, "openai-compatible"); assert_eq!(spec.config["base_url"], "https://example.invalid/v1"); @@ -229,7 +244,7 @@ mod tests { let err = resolve_config_and_provider( &args, std::path::Path::new("/tmp/lash/config.json"), - ConfigLoadOutcome::Loaded(LashConfig::new(&openai_shortcut_provider("key".into(), ""))), + ConfigLoadOutcome::Loaded(LashConfig::new(openai_shortcut("key".into(), "").0)), None, false, ) diff --git a/crates/lash-cli/src/startup/session.rs b/crates/lash-cli/src/startup/session.rs index 79b83a1..27f9d0a 100644 --- a/crates/lash-cli/src/startup/session.rs +++ b/crates/lash-cli/src/startup/session.rs @@ -1,35 +1,23 @@ -//! Opening and resuming CLI sessions: the on-disk session bootstrap (store + -//! sidecar databases + host config) and the [`CliSessionOpener`] that builds -//! a `LashCore` and opens the session on top of it. -//! -//! The bootstrap source (fresh / resume / fork-child) is matched exactly once -//! in [`SessionBootstrap::open`]; everything downstream works from the -//! resolved plan instead of re-matching the variants. +//! Build and open CLI sessions through the public Lash facade. -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::Arc; -use anyhow::Result; -use lash::{LashCore, LashSession, PluginStack, PromptLayerSink}; -use lash_core::provider::ProviderHandle; -use lash_core::runtime::RuntimeSessionState; -use lash_core::store::SessionHead; -use lash_core::{ - AttachmentStore, LeaseOwnerIdentity, PersistedSessionConfig, RuntimePersistence, SessionGraph, - SessionPolicy, -}; -use lash_sqlite_store::Store; -use lash_standard_plugins::StandardContextApproach; +use anyhow::{Context, Result}; +use lash::durability::EffectHost; +use lash::persistence::{AttachmentStore, LeaseOwnerIdentity, ProcessExecutionEnvStore}; +use lash::rlm::{RLM_PROTOCOL_PLUGIN_ID, RlmCreateExtras, RlmSessionExt}; +use lash::{LashCore, LashSession, PluginStack, PromptLayerSink, SessionSpec}; +use lash_core::{PromptLayer, SessionPolicy}; use crate::execution_settings::{ - ExecutionMode, RlmTerminationMode, default_rlm_termination_for_mode, + ExecutionMode, RlmDialect, RlmTerminationMode, default_rlm_termination_for_mode, }; -use crate::session_log::{self, SessionLogger, SessionStart}; +use crate::session_log::{self, SessionLogger}; pub(crate) enum SessionBootstrapSource { Fresh, Resume(String), - ForkChild { parent_session_id: String }, } impl SessionBootstrapSource { @@ -46,23 +34,17 @@ impl SessionBootstrapSource { } pub(crate) struct SessionBootstrap { - sessions_dir: PathBuf, - filename: String, - store: Arc, - resume_start: Option, - resume_head: Option, + session_id: String, session_name: String, host_config: Option, - /// `Some` when this session was forked from a parent session. - fork_parent_session_id: Option, - /// `true` when an existing session database was resumed. resumed: bool, } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub(crate) struct CliSessionHostConfig { pub(crate) execution_mode: ExecutionMode, - pub(crate) standard_context_approach: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) rlm_dialect: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) rlm_termination: Option, } @@ -70,18 +52,19 @@ pub(crate) struct CliSessionHostConfig { impl CliSessionHostConfig { pub(crate) fn new( execution_mode: ExecutionMode, - standard_context_approach: Option, + rlm_dialect: Option, rlm_termination: Option, ) -> Self { - let rlm_termination = if execution_mode.is_rlm() { - rlm_termination.or_else(|| default_rlm_termination_for_mode(execution_mode)) - } else { - None - }; Self { execution_mode, - standard_context_approach, - rlm_termination, + rlm_dialect: execution_mode + .is_rlm() + .then_some(rlm_dialect.unwrap_or_default()), + rlm_termination: if execution_mode.is_rlm() { + rlm_termination.or_else(|| default_rlm_termination_for_mode(execution_mode)) + } else { + None + }, } } } @@ -95,153 +78,53 @@ pub(crate) struct OpenedCliLashSession { #[derive(Clone)] pub(crate) struct CliSessionOpener { plugin_stack: PluginStack, - prompt_layer: lash_core::PromptLayer, + prompt_layer: PromptLayer, attachment_store: Arc, - provider: ProviderHandle, + provider: lash::provider::ProviderHandle, deferred_tool_resolver: Option, trace_jsonl_path: Option, trace_level: lash::tracing::TraceLevel, -} - -fn policy_with_persisted_config( - mut policy: SessionPolicy, - session_id: String, - config: Option<&PersistedSessionConfig>, -) -> SessionPolicy { - if let Some(config) = config { - policy.model = config.model.clone(); - policy.provider_id = config.provider_id.clone(); - } - policy.session_id = Some(session_id); - policy -} - -fn host_config_path(sessions_dir: &Path, filename: &str) -> PathBuf { - sessions_dir.join(format!("{filename}.host.json")) -} - -fn load_host_config(sessions_dir: &Path, filename: &str) -> Option { - let path = host_config_path(sessions_dir, filename); - std::fs::read(path) - .ok() - .and_then(|bytes| serde_json::from_slice(&bytes).ok()) -} - -fn save_host_config( - sessions_dir: &Path, - filename: &str, - config: &CliSessionHostConfig, -) -> Result<()> { - std::fs::write( - host_config_path(sessions_dir, filename), - serde_json::to_vec_pretty(config)?, - )?; - Ok(()) -} - -fn cli_runtime_lease_owner() -> Result { - Ok(LeaseOwnerIdentity::local_process( - uuid::Uuid::new_v4().to_string(), - uuid::Uuid::new_v4().to_string(), - crate::paths::host_id()?, - )) + opened_cores: Arc>>, + active_effect_host: Arc>>>, } impl SessionBootstrap { pub(crate) async fn open(source: SessionBootstrapSource) -> Result { - let sessions_dir = session_log::sessions_dir(); - std::fs::create_dir_all(&sessions_dir)?; - // The single place the bootstrap-source variants are matched: resolve - // them into a plan (filename + resume/fork markers) up front. - let (filename, fork_parent_session_id, resumed) = match source { - SessionBootstrapSource::Fresh => (session_log::new_session_filename(), None, false), - SessionBootstrapSource::ForkChild { parent_session_id } => ( - session_log::new_session_filename(), - Some(parent_session_id), - false, - ), - SessionBootstrapSource::Resume(filename) => (filename, None, true), + std::fs::create_dir_all(session_log::sessions_dir())?; + let (session_id, resumed) = match source { + SessionBootstrapSource::Fresh => (uuid::Uuid::new_v4().to_string(), false), + SessionBootstrapSource::Resume(session_id) => (session_id, true), }; - let db_path = sessions_dir.join(&filename); - if resumed && !db_path.is_file() { - return Err(anyhow::anyhow!("Could not resolve session `{filename}`")); + let roster = session_log::load_roster_entry(&session_id).ok(); + if resumed && roster.is_none() { + let old_path = session_log::sessions_dir().join(&session_id); + if old_path + .extension() + .is_some_and(|extension| extension == "db") + { + anyhow::bail!(session_log::incompatible_session_message(&old_path).await); + } + anyhow::bail!("Could not resolve session `{session_id}`"); } - let store = Arc::new(Store::open(&db_path).await?); - let (resume_start, resume_head) = if resumed { - ( - store.load_session_meta().await.map(|meta| SessionStart { - session_id: meta.session_id, - session_name: meta.session_name, - }), - store.load_session_head().await, - ) - } else { - (None, None) - }; - let session_name = match resume_start.as_ref() { - Some(start) => start.session_name.clone(), - None => crate::generate_session_name(&sessions_dir).await, - }; - let host_config = load_host_config(&sessions_dir, &filename); + let session_name = roster + .as_ref() + .map(|entry| entry.session_name.clone()) + .unwrap_or(crate::generate_session_name(&session_log::sessions_dir()).await); + let host_config = session_log::load_host_config(&session_id).ok(); Ok(Self { - sessions_dir, - filename, - store, - resume_start, - resume_head, + session_id, session_name, host_config, - fork_parent_session_id, resumed, }) } - pub(crate) fn sessions_dir(&self) -> &Path { - &self.sessions_dir - } - pub(crate) fn filename(&self) -> &str { - &self.filename - } - - pub(crate) fn store(&self) -> Arc { - Arc::clone(&self.store) - } - - fn sidecar_db_path(&self, suffix: &str) -> PathBuf { - self.sessions_dir - .join(format!("{}.{}", self.filename, suffix)) - } - - pub(crate) fn artifacts_db_file(&self) -> PathBuf { - self.sidecar_db_path("artifacts.db") - } - - pub(crate) fn effects_db_file(&self) -> PathBuf { - self.sidecar_db_path("effects.db") - } - - pub(crate) fn processes_db_file(&self) -> PathBuf { - self.sidecar_db_path("processes.db") - } - - pub(crate) fn process_env_db_file(&self) -> PathBuf { - self.sidecar_db_path("process-env.db") - } - - pub(crate) fn triggers_db_file(&self) -> PathBuf { - self.sidecar_db_path("triggers.db") + &self.session_id } pub(crate) fn run_session_id(&self) -> Option { - self.resume_start - .as_ref() - .map(|start| start.session_id.clone()) - .or_else(|| Some(uuid::Uuid::new_v4().to_string())) - } - - pub(crate) fn persisted_config(&self) -> Option { - self.resume_head.as_ref().map(|head| head.config.clone()) + Some(self.session_id.clone()) } pub(crate) fn persisted_host_config(&self) -> Option { @@ -249,14 +132,7 @@ impl SessionBootstrap { } pub(crate) fn save_host_config(&self, config: &CliSessionHostConfig) -> Result<()> { - save_host_config(&self.sessions_dir, &self.filename, config) - } - - pub(crate) fn initial_graph(&self) -> SessionGraph { - self.resume_head - .as_ref() - .map(|head| head.graph.clone()) - .unwrap_or_default() + session_log::save_host_config(&self.session_id, config) } pub(crate) fn session_name(&self) -> String { @@ -266,44 +142,25 @@ impl SessionBootstrap { pub(crate) async fn logger( &self, model: &str, - session_id: Option, + _session_id: Option, ) -> Result { - if self.resumed { - return SessionLogger::resume(Arc::clone(&self.store), &self.filename).await; - } let logger = SessionLogger::new( - Arc::clone(&self.store), - self.filename.clone(), + self.session_id.clone(), + self.session_name.clone(), model, - session_id, - self.session_name(), - ) - .await?; - if let Some(parent_session_id) = self.fork_parent_session_id.as_deref() { - logger.mark_as_child_of(parent_session_id).await?; - } + None, + )?; Ok(logger) } - - pub(crate) async fn fork_child(parent_session_id: &str, model: &str) -> Result { - let bootstrap = Self::open(SessionBootstrapSource::ForkChild { - parent_session_id: parent_session_id.to_string(), - }) - .await?; - let _logger = bootstrap - .logger(model, Some(uuid::Uuid::new_v4().to_string())) - .await?; - Ok(bootstrap) - } } impl CliSessionOpener { #[allow(clippy::too_many_arguments)] pub(crate) fn new( plugin_stack: PluginStack, - prompt_layer: lash_core::PromptLayer, + prompt_layer: PromptLayer, attachment_store: Arc, - provider: ProviderHandle, + provider: lash::provider::ProviderHandle, deferred_tool_resolver: Option, trace_jsonl_path: Option, trace_level: lash::tracing::TraceLevel, @@ -316,121 +173,167 @@ impl CliSessionOpener { deferred_tool_resolver, trace_jsonl_path, trace_level, + opened_cores: Arc::new(tokio::sync::Mutex::new(Vec::new())), + active_effect_host: Arc::new(std::sync::RwLock::new(None)), + } + } + + pub(crate) fn active_effect_host(&self) -> Arc { + self.active_effect_host + .read() + .expect("active effect-host lock poisoned") + .clone() + .expect("a Lash session always has an active effect host") + } + + pub(crate) async fn shutdown(&self) -> Result<()> { + let cores = self.opened_cores.lock().await.clone(); + for core in cores.into_iter().rev() { + core.flush_trace_sink()?; + core.shutdown().await?; } + Ok(()) + } + + pub(crate) async fn fork_at(&self, node_id: &str, child_session_id: &str) -> Result<()> { + let core = self + .opened_cores + .lock() + .await + .last() + .cloned() + .ok_or_else(|| anyhow::anyhow!("no Lash core is open"))?; + core.pin(node_id).await?; + core.fork_at(node_id, child_session_id).await?; + Ok(()) } - /// Build the `LashCore` and open the session for an already-opened - /// bootstrap. The startup pipeline opens the bootstrap once (it needs the - /// persisted host config before this point) and hands it in here instead - /// of re-opening the store. pub(crate) async fn open_prepared( &self, bootstrap: SessionBootstrap, fallback_policy: SessionPolicy, host_config: CliSessionHostConfig, ) -> Result { - let session_id = bootstrap - .run_session_id() - .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); - let policy = policy_with_persisted_config( - fallback_policy, - session_id.clone(), - bootstrap.persisted_config().as_ref(), - ); - bootstrap.save_host_config(&host_config)?; - let logger = bootstrap - .logger(&policy.model.id, Some(session_id.clone())) - .await?; - let mut state = RuntimeSessionState { - session_id: session_id.clone(), - policy: policy.clone(), - session_graph: bootstrap.initial_graph(), - ..RuntimeSessionState::default() - }; - if host_config.execution_mode.is_rlm() - && let Some(termination) = host_config.rlm_termination - { - state.protocol_turn_options = - lash_core::ProtocolTurnOptions::typed(lash_rlm_types::RlmCreateExtras { - termination: termination.as_rlm_termination(), - final_answer_format: None, - })?; - } - let store: Arc = bootstrap.store(); - let runtime_lease_owner = cli_runtime_lease_owner()?; - let effect_host = Arc::new( - lash_sqlite_store::SqliteEffectHost::open(&bootstrap.effects_db_file()).await?, - ); + std::fs::create_dir_all(crate::paths::store_dir())?; let process_registry = Arc::new( - lash_sqlite_store::SqliteProcessRegistry::open(&bootstrap.processes_db_file()).await?, + lash_sqlite_store::SqliteProcessRegistry::open( + &crate::paths::processes_db(), + crate::paths::store_dir(), + ) + .await?, ); + let store_factory: Arc = Arc::new( + lash_sqlite_store::SqliteSessionStoreFactory::new_with_process_registry( + crate::paths::store_dir(), + crate::paths::processes_db(), + ), + ); + let effect_host: Arc = + Arc::new(lash_sqlite_store::SqliteEffectHost::open(&crate::paths::effects_db()).await?); let trigger_store = Arc::new( - lash_sqlite_store::SqliteTriggerStore::open(&bootstrap.triggers_db_file()).await?, + lash_sqlite_store::SqliteTriggerStore::open(&crate::paths::triggers_db()).await?, ); - let process_env_store: Arc = - Arc::new(Store::open(&bootstrap.process_env_db_file()).await?); - let child_store_factory = Arc::new(lash_sqlite_store::SqliteSessionStoreFactory::new( - bootstrap.sessions_dir().to_path_buf(), - )); - let session = match host_config.execution_mode { + let process_env_store: Arc = + Arc::new(lash_sqlite_store::Store::open(&crate::paths::process_env_db()).await?); + let artifact_store = + Arc::new(lash_sqlite_store::Store::open(&crate::paths::artifacts_db()).await?); + + let builder = match host_config.execution_mode { ExecutionMode::Standard => { - let mut core_builder = LashCore::standard_builder() - .provider(self.provider.clone()) - .model(policy.model.clone()) - .child_store_factory(child_store_factory) - .plugins(self.plugin_stack.clone()) - .prompt_layer(self.prompt_layer.clone()) - .effect_host(effect_host) - .attachment_store(Arc::clone(&self.attachment_store)) - .process_env_store(Arc::clone(&process_env_store)) - .trace_level(self.trace_level) - .process_registry(process_registry) - .trigger_store(trigger_store); - if let Some(trace_jsonl_path) = self.trace_jsonl_path.clone() { - core_builder = core_builder.trace_jsonl_path(trace_jsonl_path); - } - core_builder - .build()? - .session(session_id) - .session_execution_owner(runtime_lease_owner.clone()) - .store(store) - .open_with_state(state) - .await? + LashCore::standard_builder(crate::host_policy::turn_budget()) } ExecutionMode::Rlm => { - let artifact_store = Arc::new(Store::open(&bootstrap.artifacts_db_file()).await?) - as Arc; - let mut factory = lash_protocol_rlm::RlmProtocolPluginFactory::new( - lash_protocol_rlm::RlmProtocolPluginConfig::default(), + let mut factory = lash::rlm::RlmProtocolPluginFactory::new( + crate::host_policy::rlm_protocol_config(), artifact_store, ); if let Some(resolver) = self.deferred_tool_resolver.clone() { factory = factory.with_deferred_tool_resolver(resolver); } - let mut core_builder = LashCore::rlm_builder(factory) - .provider(self.provider.clone()) - .model(policy.model.clone()) - .child_store_factory(child_store_factory) - .plugins(self.plugin_stack.clone()) - .prompt_layer(self.prompt_layer.clone()) - .effect_host(effect_host) - .attachment_store(Arc::clone(&self.attachment_store)) - .process_env_store(Arc::clone(&process_env_store)) - .trace_level(self.trace_level) - .process_registry(process_registry) - .trigger_store(trigger_store); - if let Some(trace_jsonl_path) = self.trace_jsonl_path.clone() { - core_builder = core_builder.trace_jsonl_path(trace_jsonl_path); - } - core_builder - .build()? - .session(session_id) - .session_execution_owner(runtime_lease_owner) - .store(store) - .open_with_state(state) - .await? + LashCore::rlm_builder(crate::host_policy::turn_budget(), factory) } }; + let mut builder = builder + .provider(self.provider.clone()) + .model(fallback_policy.model.clone()) + .no_progress_budget(crate::host_policy::no_progress_budget()) + .store_factory(store_factory) + .plugins(self.plugin_stack.clone()) + .prompt_layer(self.prompt_layer.clone()) + .effect_host(Arc::clone(&effect_host)) + .attachment_store(Arc::clone(&self.attachment_store)) + .process_env_store(process_env_store) + .commit_budget(crate::host_policy::commit_budget()) + .queued_work_batching(crate::host_policy::queued_work_batching()) + .process_registry(process_registry) + .trigger_store(trigger_store) + .trace_level(self.trace_level); + if let Some(trace_path) = self.trace_jsonl_path.clone() { + builder = builder.trace_jsonl_path(trace_path); + } + let core = builder + .build(LeaseOwnerIdentity::opaque( + crate::paths::host_id()?, + uuid::Uuid::new_v4().to_string(), + )) + .context("build Lash core")?; + if bootstrap.resumed && !core.session_exists(&bootstrap.session_id).await? { + anyhow::bail!( + "Session `{}` is present in the host roster but absent from the Lash catalog", + bootstrap.session_id + ); + } + + let session_spec = SessionSpec::new() + .provider_id(fallback_policy.provider_id.clone()) + .model(fallback_policy.model.clone()) + .turn_budget(crate::host_policy::turn_budget()) + .no_progress_budget(crate::host_policy::no_progress_budget()) + .prompt_layer(self.prompt_layer.clone()); + let mut session_builder = core + .session(bootstrap.session_id.clone()) + .session_spec(session_spec); + if host_config.execution_mode.is_rlm() { + session_builder = session_builder.plugin_option( + RLM_PROTOCOL_PLUGIN_ID, + RlmCreateExtras { + dialect: host_config.rlm_dialect, + termination: host_config + .rlm_termination + .map(RlmTerminationMode::as_rlm_termination), + final_answer_format: None, + }, + )?; + } + let session = session_builder.open().await.map_err(|error| { + let requested = host_config + .rlm_dialect + .map(RlmDialect::language_id) + .unwrap_or("none"); + anyhow::anyhow!("could not open session with RLM dialect `{requested}`: {error}") + })?; + if let Some(requested) = host_config.rlm_dialect { + let recorded = session.rlm_config().dialect.unwrap_or_default(); + if recorded != requested { + anyhow::bail!( + "RLM dialect conflict: session records `{}`, requested `{}`", + recorded.language_id(), + requested.language_id() + ); + } + } + bootstrap.save_host_config(&host_config)?; + let logger = bootstrap + .logger( + &fallback_policy.model.id, + Some(bootstrap.session_id.clone()), + ) + .await?; + *self + .active_effect_host + .write() + .expect("active effect-host lock poisoned") = Some(effect_host); + self.opened_cores.lock().await.push(core.clone()); Ok(OpenedCliLashSession { bootstrap, logger, @@ -442,7 +345,7 @@ impl CliSessionOpener { &self, fallback_policy: SessionPolicy, execution_mode: ExecutionMode, - standard_context_approach: Option, + rlm_dialect: Option, ) -> Result { let bootstrap = SessionBootstrap::open(SessionBootstrapSource::Fresh).await?; self.open_prepared( @@ -450,7 +353,7 @@ impl CliSessionOpener { fallback_policy, CliSessionHostConfig::new( execution_mode, - standard_context_approach, + rlm_dialect, default_rlm_termination_for_mode(execution_mode), ), ) @@ -462,141 +365,29 @@ impl CliSessionOpener { identifier: &str, fallback_policy: SessionPolicy, execution_mode: ExecutionMode, - standard_context_approach: Option, + rlm_dialect: Option, ) -> Result { let bootstrap = SessionBootstrap::open( SessionBootstrapSource::from_resume_arg(Some(identifier.to_string())).await, ) .await?; - let persisted_host_config = bootstrap.persisted_host_config(); + let persisted = bootstrap.persisted_host_config(); + let resolved_execution_mode = persisted + .as_ref() + .map(|config| config.execution_mode) + .unwrap_or(execution_mode); let host_config = CliSessionHostConfig::new( - persisted_host_config + resolved_execution_mode, + persisted .as_ref() - .map(|config| config.execution_mode) - .unwrap_or(execution_mode), - persisted_host_config - .as_ref() - .and_then(|config| config.standard_context_approach.clone()) - .or(standard_context_approach), - persisted_host_config + .and_then(|config| config.rlm_dialect) + .or(rlm_dialect), + persisted .as_ref() .and_then(|config| config.rlm_termination) - .or_else(|| default_rlm_termination_for_mode(execution_mode)), + .or_else(|| default_rlm_termination_for_mode(resolved_execution_mode)), ); self.open_prepared(bootstrap, fallback_policy, host_config) .await } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_support::{EnvVarGuard, TempDirGuard, env_lock}; - use lash_core::SessionMeta; - - #[test] - fn host_config_round_trips_rlm_termination_and_accepts_old_files() -> Result<()> { - let temp = TempDirGuard::new("lash-cli-host-config"); - save_host_config( - temp.path(), - "rlm.db", - &CliSessionHostConfig::new( - ExecutionMode::Rlm, - None, - Some(RlmTerminationMode::FinishRequired), - ), - )?; - let loaded = load_host_config(temp.path(), "rlm.db").expect("saved host config"); - assert_eq!(loaded.execution_mode, ExecutionMode::Rlm); - assert_eq!( - loaded.rlm_termination, - Some(RlmTerminationMode::FinishRequired) - ); - - std::fs::write( - host_config_path(temp.path(), "old.db"), - r#"{"execution_mode":"rlm","standard_context_approach":null}"#, - )?; - let old = load_host_config(temp.path(), "old.db").expect("old host config"); - assert_eq!(old.execution_mode, ExecutionMode::Rlm); - assert_eq!(old.rlm_termination, None); - Ok(()) - } - - #[tokio::test] - async fn fresh_cli_session_opens_with_durable_artifact_store() -> Result<()> { - let _env = env_lock().lock().await; - let temp = TempDirGuard::new("lash-cli-session-bootstrap"); - let _lash_home = EnvVarGuard::set("LASH_HOME", temp.path()); - let provider = lash_core::testing::TestProvider::builder() - .build() - .into_handle(); - let opener = CliSessionOpener::new( - PluginStack::new(), - lash_core::PromptLayer::new(), - Arc::new(lash::persistence::FileAttachmentStore::new( - crate::paths::attachments_dir(), - )), - provider, - None, - None, - lash::tracing::TraceLevel::Standard, - ); - let policy = SessionPolicy { - model: lash_core::ModelSpec::from_token_limits( - "mock-model", - Default::default(), - 200_000, - None, - ) - .expect("model spec"), - provider_id: "test".to_string(), - ..Default::default() - }; - - let opened = opener - .fresh( - policy, - ExecutionMode::Rlm, - Some(StandardContextApproach::default()), - ) - .await?; - - assert!(!opened.session.session_id().is_empty()); - assert!(opened.bootstrap.artifacts_db_file().is_file()); - assert!(opened.bootstrap.effects_db_file().is_file()); - assert!(opened.bootstrap.processes_db_file().is_file()); - assert!(opened.bootstrap.process_env_db_file().is_file()); - assert!(opened.bootstrap.triggers_db_file().is_file()); - let host_config = - load_host_config(opened.bootstrap.sessions_dir(), opened.bootstrap.filename()) - .expect("saved host config"); - assert_eq!( - host_config.rlm_termination, - Some(RlmTerminationMode::Natural) - ); - Ok(()) - } - - #[tokio::test] - async fn file_backed_store_creates_wal_file() { - let _env_guard = env_lock().lock().await; - let temp = TempDirGuard::new("lash-cli-store-wal"); - let _lash_home = EnvVarGuard::set("LASH_HOME", temp.path()); - let db_path = temp.path().join("session.db"); - let store = Store::open(&db_path).await.expect("store"); - - store - .save_session_meta(SessionMeta { - session_id: "s1".to_string(), - session_name: "demo".to_string(), - created_at: "2026-03-26T10:00:00Z".to_string(), - model: "gpt-5".to_string(), - cwd: Some("/tmp/demo".to_string()), - relation: lash_core::SessionRelation::Root, - }) - .await; - - assert!(db_path.with_extension("db-wal").exists()); - } -} diff --git a/crates/lash-cli/src/test_support.rs b/crates/lash-cli/src/test_support.rs index 91c6d47..53bd5ff 100644 --- a/crates/lash-cli/src/test_support.rs +++ b/crates/lash-cli/src/test_support.rs @@ -1,19 +1,8 @@ use std::path::{Path, PathBuf}; -use std::sync::Arc; use std::sync::OnceLock; -use lash_core::{SessionStreamEvent, TurnEvent}; -use lash_tui::ScreenSnapshot; -use lash_tui_extensions::TuiExtensions; use tokio::sync::Mutex; -use crate::app::{App, PreparedTurn, PromptState}; -use crate::overlay::PromptFocus; -use crate::prompt_model::{PromptRequest, PromptResponse}; -use crate::render; -use crate::ui_effects::apply_ui_host_effects; -use crate::ui_trace::{TraceRepoStatus, render_screen_snapshot}; - pub(crate) fn env_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) @@ -67,424 +56,3 @@ impl Drop for TempDirGuard { let _ = std::fs::remove_dir_all(&self.path); } } - -pub(crate) struct UiHarness { - pub app: App, - pub width: u16, - pub height: u16, - ui_extensions: Arc, -} - -impl UiHarness { - pub(crate) fn new(width: u16, height: u16) -> Self { - let ui_extensions = Arc::new(TuiExtensions::builtin().expect("builtin ui extensions")); - let mut app = App::new( - "gpt-5.4".to_string(), - "test-session".to_string(), - "test-session-id".into(), - ); - let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("lash-cli crate should live under repo root") - .to_path_buf(); - app.cwd = repo_root.display().to_string(); - app.repo_status = Some( - TraceRepoStatus { - repo_name: "lash".to_string(), - branch: "staging".to_string(), - worktree: None, - } - .into_repo_status(&app.cwd), - ); - app.set_ui_extensions(Arc::clone(&ui_extensions)); - Self { - app, - width, - height, - ui_extensions, - } - } - - pub(crate) fn start_turn(&mut self) -> &mut Self { - self.app.start_turn(); - self - } - - pub(crate) fn user_turn(&mut self, text: impl Into) -> PreparedTurn { - let turn = PreparedTurn::new(text.into(), Vec::new()); - self.app.push_prepared_user_input(&turn); - turn - } - - pub(crate) fn queue_turn(&mut self, text: impl Into) -> PreparedTurn { - let turn = PreparedTurn::new(text.into(), Vec::new()); - self.app - .test_seed_queued_turn_snapshot(turn.clone(), lash_core::TurnInputIngress::NextTurn); - turn - } - - pub(crate) fn dispatch_event(&mut self, event: SessionStreamEvent) -> &mut Self { - let effects = test_session_event_to_turn_event(&event) - .map(|event| self.ui_extensions.effects_for_turn_event(&event)) - .unwrap_or_default(); - self.app.handle_session_event(event); - apply_ui_host_effects(&mut self.app, effects); - self - } - - pub(crate) fn emit_prompt( - &mut self, - request: PromptRequest, - ) -> std::sync::mpsc::Receiver { - let (tx, rx) = std::sync::mpsc::channel(); - self.show_prompt(request, tx); - rx - } - - pub(crate) fn show_prompt( - &mut self, - request: PromptRequest, - response_tx: std::sync::mpsc::Sender, - ) { - let focus = if request.is_freeform() { - PromptFocus::Text - } else { - PromptFocus::Options - }; - self.app.show_prompt(PromptState { - request, - focus, - cursor: 0, - scroll_offset: 0, - selected: Default::default(), - reply_text: String::new(), - reply_cursor: 0, - response_tx, - }); - } - - pub(crate) fn take_prompt_response(&mut self) -> Option { - self.app.take_prompt_response() - } - - pub(crate) fn render(&mut self) -> ScreenSnapshot { - render_screen_snapshot(&mut self.app, self.width, self.height) - } -} - -fn test_session_event_to_turn_event(event: &SessionStreamEvent) -> Option { - match event { - SessionStreamEvent::LlmRequest { - protocol_iteration, .. - } => Some(TurnEvent::ModelRequestStarted { - protocol_iteration: *protocol_iteration, - }), - SessionStreamEvent::ToolCall { - call_id, - name, - args, - output, - duration_ms, - } => Some(TurnEvent::ToolCallCompleted { - call_id: call_id.clone(), - name: name.clone(), - args: args.clone(), - output: output.clone(), - duration_ms: *duration_ms, - graph_key: None, - parent_call_id: None, - }), - SessionStreamEvent::PluginEvent { plugin_id, event } => Some(TurnEvent::PluginRuntime { - plugin_id: plugin_id.clone(), - event: event.clone(), - }), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use lash_core::{PluginRuntimeEvent, SessionStreamEvent}; - use serde_json::json; - - fn line_index_containing(lines: &[String], needle: &str) -> Option { - lines.iter().position(|line| line.contains(needle)) - } - - #[test] - fn ui_harness_renders_streamed_turn_end_to_end() { - let mut harness = UiHarness::new(50, 14); - harness.user_turn("write a poem"); - harness.start_turn(); - harness.dispatch_event(SessionStreamEvent::LlmRequest { - protocol_iteration: 0, - message_count: 1, - tool_list: "read_file, shell".to_string(), - }); - harness.dispatch_event(SessionStreamEvent::TextDelta { - content: "A short line.\nAnother line.".to_string(), - }); - - let screen = harness.render(); - let lines = screen.non_empty_visible_lines(); - assert!(lines.iter().any(|line| line.contains("write a poem"))); - assert!(lines.iter().any(|line| line.contains("A short line."))); - assert!(lines.iter().any(|line| line.contains("Another line."))); - } - - #[test] - fn ui_harness_intercepts_prompt_and_renders_overlay() { - let mut harness = UiHarness::new(60, 16); - let rx = harness.emit_prompt( - PromptRequest::single("Pick one", vec!["Alpha".into(), "Beta".into()]) - .with_optional_note(), - ); - harness.app.prompt_down(); - harness.app.prompt_toggle_note_focus(); - harness.app.prompt_insert_text("because"); - - let screen = harness.render(); - let lines = screen.non_empty_visible_lines(); - assert!(harness.app.has_prompt()); - assert!(lines.iter().any(|line| line.contains("because"))); - - let display = harness.take_prompt_response().expect("prompt display"); - assert!(display.contains("2. Beta")); - let response = rx.recv().expect("prompt response"); - assert_eq!( - response, - PromptResponse::Single { - selection: "Beta".to_string(), - note: Some("because".to_string()), - } - ); - } - - #[test] - fn ui_harness_renders_inline_ask_panel_end_to_end() { - let mut harness = UiHarness::new(68, 20); - harness.user_turn("ask me"); - harness.dispatch_event(SessionStreamEvent::ToolCall { - call_id: Some("call-1".into()), - name: "ask".into(), - args: json!({ - "question": "Pick one", - "options": ["Alpha", "Beta", "Gamma"], - }), - output: lash_core::ToolCallOutput::success(json!({ - "kind": "single", - "selection": "Beta", - "note": "because", - })), - duration_ms: 12, - }); - - let screen = harness.render(); - let lines = screen.non_empty_visible_lines(); - assert!(lines.iter().any(|line| line.contains("QUESTION"))); - assert!(lines.iter().any(|line| line.contains("Pick one"))); - assert!(lines.iter().any(|line| line.contains("◉ 2. Beta"))); - assert!(lines.iter().any(|line| line.contains("Note · because"))); - } - - #[test] - fn ui_harness_renders_queue_preview_sections_end_to_end() { - let mut harness = UiHarness::new(72, 18); - harness.start_turn(); - harness.app.test_seed_queued_turn_snapshot( - PreparedTurn::new("after tool do this".into(), Vec::new()), - lash_core::TurnInputIngress::active_turn( - "test-turn", - lash_core::TurnInputCheckpointBoundary::AfterWork, - ), - ); - harness.queue_turn("queued follow-up one"); - harness.queue_turn("queued follow-up two"); - harness.queue_turn("queued follow-up three"); - - let screen = harness.render(); - let lines = screen.non_empty_visible_lines(); - - assert!( - lines - .iter() - .any(|line| line.contains("Will send in this turn")) - ); - assert!(lines.iter().any(|line| line.contains("after tool do this"))); - assert!( - lines - .iter() - .any(|line| line.contains("Queued for next turn · 3")) - ); - assert!( - lines - .iter() - .any(|line| line.contains("queued follow-up one")) - ); - assert!(lines.iter().any(|line| line.contains("+1 more"))); - } - - #[test] - fn ui_harness_renders_accepted_injected_turn_input_during_active_turn() { - let mut harness = UiHarness::new(72, 18); - harness.user_turn("initial question"); - harness.start_turn(); - harness - .app - .cache_draft_presentation(PreparedTurn::new("follow up now".into(), Vec::new())); - harness.dispatch_event(SessionStreamEvent::InjectedTurnInputAccepted { - inputs: vec![lash_core::AcceptedInjectedTurnInput { - id: None, - message: lash_core::PluginMessage::text( - lash_core::MessageRole::User, - "follow up now", - ), - }], - checkpoint: lash_core::CheckpointKind::AfterWork, - }); - harness.dispatch_event(SessionStreamEvent::TextDelta { - content: "I saw the follow up.".into(), - }); - - let screen = harness.render(); - let lines = screen.non_empty_visible_lines(); - - assert!(lines.iter().any(|line| line.contains("initial question"))); - assert!(lines.iter().any(|line| line.contains("follow up now"))); - assert!( - lines - .iter() - .any(|line| line.contains("I saw the follow up.")) - ); - } - - #[test] - fn ui_harness_renders_plugin_panel_and_mode_indicator_end_to_end() { - let mut harness = UiHarness::new(72, 20); - harness.dispatch_event(SessionStreamEvent::PluginEvent { - plugin_id: "plan_mode".into(), - event: PluginRuntimeEvent::Custom { - name: "plan_mode.state".into(), - payload: json!({ - "session_id": "test-session", - "enabled": true, - "plan_path": ".lash/plans/test-session.md" - }), - }, - }); - - let screen = harness.render(); - let lines = screen.visible_lines_trimmed(); - - assert!(lines.iter().any(|line| line.contains("PLAN"))); - assert!( - lines - .iter() - .any(|line| line.contains(".lash/plans/test-session.md")) - ); - assert!( - lines - .iter() - .any(|line| line.contains("standard") && line.contains("plan")) - ); - assert!(lines.iter().any(|line| line.contains("staging"))); - } - - #[test] - fn ui_harness_scroll_and_follow_behaves_end_to_end() { - let mut harness = UiHarness::new(36, 12); - harness.user_turn("write lines"); - harness.start_turn(); - harness.dispatch_event(SessionStreamEvent::LlmRequest { - protocol_iteration: 0, - message_count: 1, - tool_list: "read_file".into(), - }); - harness.dispatch_event(SessionStreamEvent::TextDelta { - content: (0..10) - .map(|idx| format!("line {idx}")) - .collect::>() - .join("\n"), - }); - harness.render(); - harness.dispatch_event(SessionStreamEvent::TextDelta { - content: (10..18) - .map(|idx| format!("\nline {idx}")) - .collect::(), - }); - - let followed = harness.render().visible_lines_trimmed(); - assert!(followed.iter().any(|line| line.contains("line 17"))); - assert!( - !followed.iter().any(|line| line.contains("line 0")), - "tail-follow should keep the newest streamed text visible" - ); - - harness.app.scroll_up(4); - let scrolled = harness.render().visible_lines_trimmed(); - assert!( - !scrolled.iter().any(|line| line.contains("line 17")), - "manual scroll should pause tail-follow" - ); - let anchored_line = scrolled - .iter() - .find(|line| line.contains("line ")) - .cloned() - .expect("older visible line after scrolling"); - - harness.dispatch_event(SessionStreamEvent::TextDelta { - content: "\nline 18\nline 19".into(), - }); - let paused = harness.render().visible_lines_trimmed(); - assert!(paused.iter().any(|line| line == &anchored_line)); - assert!(!paused.iter().any(|line| line.contains("line 19"))); - - let viewport_height = - render::history_viewport_height(&harness.app, harness.width, harness.height); - harness - .app - .scroll_down(usize::MAX / 2, viewport_height, harness.width as usize); - let resumed = harness.render().visible_lines_trimmed(); - assert!(resumed.iter().any(|line| line.contains("line 19"))); - } - - #[test] - fn ui_harness_first_visible_output_keeps_context_before_switching_to_tail_follow() { - let mut harness = UiHarness::new(44, 12); - harness.user_turn("write a very long answer please"); - harness.start_turn(); - harness.dispatch_event(SessionStreamEvent::LlmRequest { - protocol_iteration: 0, - message_count: 1, - tool_list: String::new(), - }); - - let before = harness.render().visible_lines_trimmed(); - assert!( - before - .iter() - .any(|line| line.contains("write a very long answer please")) - ); - - harness.dispatch_event(SessionStreamEvent::TextDelta { - content: "first line\nsecond line\nthird line\nfourth line\nfifth line\nsixth line" - .into(), - }); - let first_output = harness.render().visible_lines_trimmed(); - let prompt_idx = line_index_containing(&first_output, "write a very long answer please") - .expect("prompt line"); - let first_idx = - line_index_containing(&first_output, "first line").expect("first output line"); - assert!( - prompt_idx < first_idx, - "prompt context should stay visible above first output" - ); - - harness.dispatch_event(SessionStreamEvent::TextDelta { - content: "\nseventh line\neighth line\nninth line\ntenth line".into(), - }); - let tailed = harness.render().visible_lines_trimmed(); - assert!(tailed.iter().any(|line| line.contains("tenth line"))); - } -} diff --git a/crates/lash-cli/src/tree.rs b/crates/lash-cli/src/tree.rs index 2050e93..15db593 100644 --- a/crates/lash-cli/src/tree.rs +++ b/crates/lash-cli/src/tree.rs @@ -1,20 +1,92 @@ use lash::LashSession; -use lash_core::runtime::RuntimeSessionState; -use lash_core::{Message, MessageRole, SessionMessageTreeNode}; +use lash_core::facade_support::SessionNodeRecordFacadeOps; +use lash_core::{Message, MessageRole}; +use std::collections::{HashMap, HashSet}; use crate::app::{App, timeline_from_read_view}; use crate::overlay::TreeSelection; -use crate::persistence::persist_committed_runtime_state; use crate::session_log::SessionLogger; +#[derive(Clone, Debug)] +pub struct SessionMessageTreeNode { + pub node_id: String, + pub parent_message_node_id: Option, + pub message: Message, + pub timestamp: String, + pub children: Vec, + pub active: bool, +} + pub async fn current_message_tree(session: &LashSession) -> Vec { - session.read_view().message_tree() + let read_view = session.read_view(); + let graph = read_view.session_graph(); + let by_id = graph + .nodes + .iter() + .map(|node| (node.node_id.as_str(), node)) + .collect::>(); + let mut active = HashSet::new(); + let mut cursor = graph.leaf_node_id.as_deref(); + while let Some(node_id) = cursor { + active.insert(node_id.to_string()); + cursor = by_id + .get(node_id) + .and_then(|node| node.parent_node_id.as_deref()); + } + let mut flat = Vec::new(); + for node in &graph.nodes { + let Some(message) = node.message() else { + continue; + }; + let mut parent = node.parent_node_id.as_deref(); + let parent_message_node_id = loop { + let Some(parent_id) = parent else { break None }; + let Some(parent_node) = by_id.get(parent_id) else { + break None; + }; + if parent_node.message().is_some() { + break Some(parent_id.to_string()); + } + parent = parent_node.parent_node_id.as_deref(); + }; + flat.push(SessionMessageTreeNode { + node_id: node.node_id.clone(), + parent_message_node_id, + message, + timestamp: node.timestamp.clone(), + children: Vec::new(), + active: active.contains(&node.node_id), + }); + } + build_tree(flat) +} + +fn build_tree(nodes: Vec) -> Vec { + let mut children_by_parent = HashMap::, Vec>::new(); + for node in nodes { + children_by_parent + .entry(node.parent_message_node_id.clone()) + .or_default() + .push(node); + } + fn children( + parent: Option, + all: &mut HashMap, Vec>, + ) -> Vec { + let mut nodes = all.remove(&parent).unwrap_or_default(); + nodes.sort_by(|left, right| left.timestamp.cmp(&right.timestamp)); + for node in &mut nodes { + node.children = children(Some(node.node_id.clone()), all); + } + nodes + } + children(None, &mut children_by_parent) } #[allow(clippy::too_many_arguments)] pub async fn switch_to_tree_selection( session: &LashSession, - logger: &SessionLogger, + _logger: &SessionLogger, app: &mut App, history: &mut Vec, selection: TreeSelection, @@ -35,42 +107,24 @@ pub async fn switch_to_tree_selection( // branch is a no-op. Skip the full `branch_to_node` rebuild — // `from_state` walks the plugin host and re-projects the // transcript, which is expensive to pay for a visible no-op. - let current_leaf = session - .read_view() - .materialized_session_graph() - .leaf_node_id - .clone(); + let current_leaf = session.read_view().session_graph().leaf_node_id.clone(); if current_leaf == target_leaf && seeded_input.is_empty() { return Ok(()); } - let state = session - .admin() - .state() - .branch_to_node(target_leaf) - .await - .map_err(|err| err.to_string())?; - let read_view = state.read_view(); + if current_leaf != target_leaf { + return Err( + "Lash does not expose a facade operation for changing the active history branch" + .to_string(), + ); + } + let read_view = session.read_view(); *history = read_view.messages().to_vec(); - - app.stop_turn(); app.timeline = timeline_from_read_view(&read_view, &app.ui_projection_state()); - app.usage.token_usage = state.token_usage.clone(); - app.usage.last_prompt_usage = state.last_prompt_usage.clone(); - // Branching to a different leaf means the handle maps from the - // old path (shell sessions, subagent tasks) are no longer valid - // — reset them so a stale id from the abandoned branch doesn't - // leak into the new branch's projector dispatch. - app.activity_state.reset(); - app.editor.pending_images.clear(); - app.editor.pending_large_pastes.clear(); app.set_input(seeded_input); app.update_suggestions(); app.invalidate_height_cache(); app.scroll_to_bottom(); - let mut persistence_state = RuntimeSessionState::from_snapshot(state); - persist_committed_runtime_state(logger.store().as_ref(), &mut persistence_state).await; - Ok(()) } diff --git a/crates/lash-cli/src/turn_runner.rs b/crates/lash-cli/src/turn_runner.rs index 08ef8ec..581de99 100644 --- a/crates/lash-cli/src/turn_runner.rs +++ b/crates/lash-cli/src/turn_runner.rs @@ -1,13 +1,12 @@ use lash::CancellationToken; use lash::LashSession; +use lash::TurnExecutionMetrics; use lash::TurnInput; +use lash::turn::TurnIssue; +use lash_core::TokenUsage; +use lash_core::facade_support::{AssistantOutput, OutputState}; use lash_core::runtime::RuntimeSessionState; -use lash_core::{ - AssistantOutput, ExecutionScope, ExecutionSummary, OutputState, TokenUsage, TurnIssue, - TurnOutcome, TurnStop, -}; -#[cfg(test)] -use lash_sqlite_store::Store; +use std::sync::Arc; use tokio::sync::oneshot; use tokio::task::JoinHandle; @@ -17,19 +16,21 @@ use crate::input_items::build_items_from_editor_input; /// Returned by the spawned session task after the app-owned session has been updated in place. pub(crate) struct RuntimeRunResult { pub(crate) stream_id: u64, - pub(crate) result: lash::TurnResult, + pub(crate) result: lash::TurnReport, } pub(crate) fn make_turn_input(turn: &PreparedTurn) -> TurnInput { - let (items, image_blobs) = - build_items_from_editor_input(&turn.effective_text, turn.images.clone()); - TurnInput::items(items).with_image_blobs(image_blobs) + TurnInput::items(build_items_from_editor_input( + &turn.effective_text, + turn.images.clone(), + )) } pub(crate) fn spawn_session_turn( session: LashSession, turn_input: TurnInput, stream_id: u64, + effect_host: Arc, ) -> (CancellationToken, oneshot::Receiver) { let (return_tx, return_rx) = oneshot::channel(); let cancel = CancellationToken::new(); @@ -41,17 +42,16 @@ pub(crate) fn spawn_session_turn( tracing::debug!(stream_id, "runtime turn task spawned"); let result = match async { let turn_id = format!("cli-turn:{stream_id}"); - let effect_host = task_session.effect_host(); - let scoped = effect_host.scoped(ExecutionScope::turn( - task_session.session_id(), - turn_id.clone(), - ))?; + let scope = lash::runtime::ExecutionScope::turn(task_session.session_id(), &turn_id); + let scoped_effects = effect_host + .scoped_static(scope)? + .expect("the SQLite CLI effect host always provides a static scope"); task_session .turn(turn_input) .turn_id(turn_id) .cancel(task_cancel) .advanced() - .stream_to_with_scope(&lash::runtime::NoopTurnActivitySink, scoped) + .stream_to_with_scope(&lash::runtime::NoopTurnActivitySink, scoped_effects) .await } .await @@ -75,8 +75,8 @@ pub(crate) fn spawn_session_turn( pub(crate) fn spawn_session_queued_turn( session: LashSession, - batch_ids: Vec, stream_id: u64, + effect_host: Arc, ) -> (CancellationToken, oneshot::Receiver) { let (return_tx, return_rx) = oneshot::channel(); let cancel = CancellationToken::new(); @@ -86,32 +86,28 @@ pub(crate) fn spawn_session_queued_turn( let task = tokio::spawn(async move { tracing::debug!(stream_id, "queued runtime turn task spawned"); - let drain_id = batch_ids - .first() - .cloned() - .unwrap_or_else(|| format!("cli-queue-drain:{stream_id}")); let result = match async { - let effect_host = task_session.effect_host(); - let scoped = effect_host.scoped(ExecutionScope::queue_drain( - task_session.session_id(), - drain_id.clone(), - ))?; + let drain_id = format!("cli-queue-drain:{stream_id}"); + let scope = + lash::runtime::ExecutionScope::queue_drain(task_session.session_id(), &drain_id); + let scoped_effects = effect_host + .scoped_static(scope)? + .expect("the SQLite CLI effect host always provides a static scope"); task_session .queued_turn() - .batch_ids(batch_ids) .drain_id(drain_id) .cancel(task_cancel) .advanced() - .stream_to_with_scope(&lash::runtime::NoopTurnActivitySink, scoped) + .stream_to_with_scope(&lash::runtime::NoopTurnActivitySink, scoped_effects) .await } .await { - Ok(Some(turn)) => turn, - Ok(None) => { + Ok(lash::QueuedTurnDrain::Ran(turn)) => turn, + Ok(lash::QueuedTurnDrain::Empty(reason)) => { runtime_error_turn_result( &task_session, - "no durable queued work was ready".to_string(), + format!("no durable queued work was ready: {}", reason.as_str()), ) .await } @@ -135,7 +131,7 @@ async fn return_turn_result( task_label: &'static str, stream_id: u64, session: LashSession, - task: JoinHandle, + task: JoinHandle, return_tx: oneshot::Sender, ) { let result = match task.await { @@ -160,19 +156,18 @@ async fn return_turn_result( let _ = return_tx.send(RuntimeRunResult { stream_id, result }); } -async fn runtime_error_turn_result(session: &LashSession, message: String) -> lash::TurnResult { +async fn runtime_error_turn_result(session: &LashSession, message: String) -> lash::TurnReport { let state = session .admin() .state() .persist_current() .await - .unwrap_or_else(|_| RuntimeSessionState::default()); + .unwrap_or_else(|_| RuntimeSessionState::new(session.policy_snapshot())); let state = state.to_snapshot(); - lash::TurnResult { - execution: ExecutionSummary::default(), + lash::TurnReport { + execution: TurnExecutionMetrics::default(), state, - outcome: TurnOutcome::Stopped(TurnStop::RuntimeError), - cancellation: None, + outcome: lash::TurnOutcome::Stopped(lash::TurnStop::RuntimeError), assistant_output: AssistantOutput { safe_text: String::new(), raw_text: String::new(), @@ -191,119 +186,6 @@ async fn runtime_error_turn_result(session: &LashSession, message: String) -> la retryable: None, provider_failure_kind: None, }], - } -} - -#[cfg(test)] -mod tests { - use super::*; - use lash::usage::TokenLedgerEntry; - use lash_core::session_model::fresh_message_id; - use lash_core::{ - HydratedSessionCheckpoint, Message, MessageRole, Part, PartKind, PersistedSessionConfig, - PersistedTurnState, PruneState, SessionGraph, SessionHead, refresh_persisted_session_state, - }; - - #[tokio::test] - async fn refresh_runtime_persistence_state_recovers_latest_token_ledger() { - let store = Store::memory().await.expect("store"); - let mut graph = SessionGraph::default(); - graph.append_message(Message { - id: fresh_message_id(), - role: MessageRole::User, - parts: vec![Part { - id: "p0".into(), - kind: PartKind::Text, - content: "hello".into(), - attachment: None, - tool_call_id: None, - tool_name: None, - tool_replay: None, - prune_state: PruneState::Intact, - reasoning_meta: None, - response_meta: None, - }] - .into(), - origin: None, - }); - let ledger = vec![TokenLedgerEntry { - source: "turn".into(), - model: "gpt-5.4-mini".into(), - usage: TokenUsage { - input_tokens: 42, - output_tokens: 11, - cache_read_input_tokens: 6, - cache_write_input_tokens: 0, - reasoning_output_tokens: 8, - }, - }]; - let checkpoint_ref = store - .put_checkpoint(&HydratedSessionCheckpoint { - turn_state: PersistedTurnState { - turn_index: 2, - token_usage: TokenUsage { - input_tokens: 30, - output_tokens: 7, - cache_read_input_tokens: 5, - cache_write_input_tokens: 0, - reasoning_output_tokens: 6, - }, - last_prompt_usage: None, - protocol_turn_options: Default::default(), - }, - tool_state_ref: None, - tool_state: None, - plugin_snapshot_ref: None, - plugin_snapshot_revision: None, - plugin_snapshot: None, - execution_state_ref: None, - execution_state: None, - }) - .await - .checkpoint_ref; - store.append_usage_deltas(&ledger).await; - store - .save_session_head(SessionHead { - session_id: "root".to_string(), - head_revision: 0, - agent_frames: Vec::new(), - current_agent_frame_id: String::new(), - graph: graph.clone(), - config: PersistedSessionConfig { - provider_id: "openai-compatible".into(), - model: lash_core::ModelSpec::from_token_limits( - "gpt-5.4-mini", - Default::default(), - 200_000, - None, - ) - .expect("valid model spec"), - }, - checkpoint_ref: Some(checkpoint_ref), - token_ledger: ledger, - }) - .await; - - let mut persistence_state = RuntimeSessionState { - session_graph: SessionGraph::default(), - token_ledger: Vec::new(), - ..RuntimeSessionState::default() - }; - refresh_persisted_session_state(&store, &mut persistence_state) - .await - .expect("refresh persisted session state"); - let stale_state = persistence_state; - - assert_eq!(stale_state.turn_index, 2); - assert_eq!(stale_state.read_view().messages().len(), 1); - assert_eq!(stale_state.token_ledger.len(), 1); - assert_eq!(stale_state.token_ledger[0].source, "turn"); - assert_eq!(stale_state.token_ledger[0].model, "gpt-5.4-mini"); - assert_eq!(stale_state.token_ledger[0].usage.input_tokens, 42); - assert_eq!(stale_state.token_ledger[0].usage.output_tokens, 11); - assert_eq!(stale_state.token_ledger[0].usage.cache_read_input_tokens, 6); - assert_eq!(stale_state.token_ledger[0].usage.reasoning_output_tokens, 8); - assert_eq!(stale_state.token_usage.input_tokens, 30); - assert_eq!(stale_state.token_usage.output_tokens, 7); + acceptance: None, } } diff --git a/crates/lash-cli/src/ui_effects.rs b/crates/lash-cli/src/ui_effects.rs index 3acaf2d..e8fba54 100644 --- a/crates/lash-cli/src/ui_effects.rs +++ b/crates/lash-cli/src/ui_effects.rs @@ -2,7 +2,7 @@ //! shared system-message and UI-snapshot helpers. use lash::{LashSession, TurnEvent, TurnInput}; -use lash_core::{PluginOwned, PluginRuntimeEvent}; +use lash_core::{PluginRuntimeEvent, facade_support::PluginOwned}; use lash_tui_extensions::{TuiExtensions, TuiHostEffect}; use crate::app::{App, PluginPanelBlock, UiTimelineItem}; @@ -218,14 +218,9 @@ fn replay_plugin_operation_receipt( /// grant-entry handle shape, so this is the one boundary that maps the unified /// facade vocabulary back to it. pub(crate) fn observed_to_handle_summary( - process: lash_core::ObservedProcess, -) -> lash_core::ProcessHandleSummary { - lash_core::ProcessHandleSummary::new( - process.process_id, - lash_core::ProcessHandleDescriptor::new(Some(process.kind), Some(process.label)), - process.lifecycle, - ) - .with_definition(process.identity.definition) + process: lash::process::ObservedProcess, +) -> lash::process::ProcessHandleView { + lash::process::ProcessHandleView::new(process.process_id, process.identity, process.lifecycle) } fn push_plugin_operation_message(app: &mut App, output: &serde_json::Value) { diff --git a/crates/lash-cli/src/ui_perf/export_cases.rs b/crates/lash-cli/src/ui_perf/export_cases.rs index 30f4120..7715956 100644 --- a/crates/lash-cli/src/ui_perf/export_cases.rs +++ b/crates/lash-cli/src/ui_perf/export_cases.rs @@ -18,6 +18,7 @@ pub(crate) fn run_html_export_once(workload: UiPerfWorkload) -> UiPerfRunResult let prompts = export_prompt_snapshots(workload.turn_count); let session = LoadedSession { meta: None, + model_id: Some("gpt-5.4".to_string()), chronological, trace_path: PathBuf::from("ui-perf.trace.jsonl"), context_window_tokens: Some(200_000), diff --git a/crates/lash-cli/src/ui_perf/projection_cases.rs b/crates/lash-cli/src/ui_perf/projection_cases.rs index 81bb051..447f58c 100644 --- a/crates/lash-cli/src/ui_perf/projection_cases.rs +++ b/crates/lash-cli/src/ui_perf/projection_cases.rs @@ -1,9 +1,8 @@ use std::time::Instant; use lash_core::{ - Message, MessageRole, Part, PartKind, PruneState, SessionReadView, SessionSnapshot, - ToolCallOutput, ToolCancellation, ToolFailure, ToolFailureClass, ToolResult, TurnActivity, - TurnEvent, + Message, MessageRole, Part, PartKind, SessionReadView, SessionSnapshot, ToolCallOutput, + ToolCancellation, ToolFailure, ToolFailureClass, ToolOutcome, TurnActivity, TurnEvent, }; use serde_json::json; @@ -106,7 +105,7 @@ pub(crate) fn run_turn_interrupt_steer_reconciliation_once( app.finish_turn_from_read_view(&read_view); app.start_turn(); app.handle_turn_activity(TurnActivity::independent(TurnEvent::AssistantProseDelta { - text: "I am midway through the current answer while a steer arrives.".to_string(), + text: "I am midway through the current answer while a steer arrives.".into(), })); let active = PreparedTurn::prepare_with_effective_text( @@ -294,6 +293,8 @@ pub(crate) fn build_projection_read_view(turn_count: usize) -> SessionReadView { .to_string(), output: vec!["time".to_string()], images: Vec::new(), + calls: Vec::new(), + calls_omitted: 0, error: None, final_output: (turn % 10 == 0).then(|| json!("RLM final output")), }, @@ -304,7 +305,9 @@ pub(crate) fn build_projection_read_view(turn_count: usize) -> SessionReadView { let state = SessionSnapshot { session_graph: graph, - ..SessionSnapshot::default() + ..SessionSnapshot::new(lash_core::SessionPolicy::new( + crate::host_policy::turn_budget(), + )) }; SessionReadView::from_snapshot(&state) } @@ -315,10 +318,10 @@ fn live_activity_event(index: usize) -> TurnEvent { protocol_iteration: index / 14, }, 1 => TurnEvent::ReasoningDelta { - text: format!("reasoning chunk {index}\n"), + text: format!("reasoning chunk {index}\n").into(), }, 2 => TurnEvent::AssistantProseDelta { - text: format!("assistant prose chunk {index}\n"), + text: format!("assistant prose chunk {index}\n").into(), }, 3 => TurnEvent::ToolCallStarted { call_id: Some(format!("shell-{index}")), @@ -430,7 +433,7 @@ fn live_activity_event(index: usize) -> TurnEvent { call_id: Some(format!("generic-{index}")), name: "search_tools".to_string(), args: json!({ "query": "projection tools" }), - output: ToolResult::err(json!("tool search failed")) + output: ToolOutcome::err(json!("tool search failed")) .into_done_output() .expect("static failure output"), duration_ms: 4, @@ -463,17 +466,27 @@ fn assistant_message(id: &str, reasoning: &str, text: &str) -> Message { } fn part(id: &str, kind: PartKind, content: &str) -> Part { - Part { - id: id.to_string(), - kind, - content: content.to_string(), - attachment: None, - tool_call_id: None, - tool_name: None, - tool_replay: None, - prune_state: PruneState::Intact, - reasoning_meta: None, - response_meta: None, + match kind { + PartKind::Text => Part::text(id.to_string(), content.to_string(), None), + PartKind::Attachment => Part::attachment_part(id.to_string(), content.to_string(), None), + PartKind::Code => Part::code(id.to_string(), content.to_string()), + PartKind::Output => Part::output(id.to_string(), content.to_string()), + PartKind::Error => Part::error(id.to_string(), content.to_string()), + PartKind::Prose => Part::prose(id.to_string(), content.to_string(), None), + PartKind::ToolCall => Part::tool_call( + id.to_string(), + content.to_string(), + String::new(), + String::new(), + None, + ), + PartKind::ToolResult => Part::tool_result( + id.to_string(), + content.to_string(), + String::new(), + String::new(), + ), + PartKind::Reasoning => Part::reasoning(id.to_string(), content.to_string(), None), } } diff --git a/crates/lash-cli/src/ui_perf/surface_fixture.rs b/crates/lash-cli/src/ui_perf/surface_fixture.rs index 69c0cd4..dcb6715 100644 --- a/crates/lash-cli/src/ui_perf/surface_fixture.rs +++ b/crates/lash-cli/src/ui_perf/surface_fixture.rs @@ -235,34 +235,40 @@ impl TuiExtension for SurfaceBenchmarkTuiExtension { fn handle_turn_event(&self, event: &lash_core::TurnEvent) -> Vec { match event { - lash_core::TurnEvent::AssistantProseDelta { text } if text == "ui_perf_mount" => vec![ - TuiHostEffect::MountSurface { - spec: TuiSurfaceSpec { - key: "workspace".to_string(), - slot: TuiSurfaceSlot::Workspace, - size: TuiSurfaceSize::Auto, - order: 0, - focusable: true, - visible: true, - modal: false, + lash_core::TurnEvent::AssistantProseDelta { text } + if text.as_ref() == "ui_perf_mount" => + { + vec![ + TuiHostEffect::MountSurface { + spec: TuiSurfaceSpec { + key: "workspace".to_string(), + slot: TuiSurfaceSlot::Workspace, + size: TuiSurfaceSize::Auto, + order: 0, + focusable: true, + visible: true, + modal: false, + }, }, - }, - TuiHostEffect::MountSurface { - spec: TuiSurfaceSpec { - key: "footer".to_string(), - slot: TuiSurfaceSlot::Footer, - size: TuiSurfaceSize::Lines(1), - order: 0, - focusable: false, - visible: true, - modal: false, + TuiHostEffect::MountSurface { + spec: TuiSurfaceSpec { + key: "footer".to_string(), + slot: TuiSurfaceSlot::Footer, + size: TuiSurfaceSize::Lines(1), + order: 0, + focusable: false, + visible: true, + modal: false, + }, }, - }, - TuiHostEffect::FocusSurface { - key: "workspace".to_string(), - }, - ], - lash_core::TurnEvent::AssistantProseDelta { text } if text == "ui_perf_overlay" => { + TuiHostEffect::FocusSurface { + key: "workspace".to_string(), + }, + ] + } + lash_core::TurnEvent::AssistantProseDelta { text } + if text.as_ref() == "ui_perf_overlay" => + { vec![ TuiHostEffect::MountSurface { spec: TuiSurfaceSpec { @@ -307,7 +313,7 @@ pub(crate) fn build_benchmark_harness( apply_ui_host_effects( &mut app, ui_extensions.effects_for_turn_event(&lash_core::TurnEvent::AssistantProseDelta { - text: "ui_perf_mount".to_string(), + text: "ui_perf_mount".into(), }), ); if scenario == UiPerfScenario::WorkspaceOverlay { @@ -315,7 +321,7 @@ pub(crate) fn build_benchmark_harness( &mut app, ui_extensions.effects_for_turn_event( &lash_core::TurnEvent::AssistantProseDelta { - text: "ui_perf_overlay".to_string(), + text: "ui_perf_overlay".into(), }, ), ); diff --git a/crates/lash-cli/src/ui_trace.rs b/crates/lash-cli/src/ui_trace.rs index 96df0c4..9a7a0c3 100644 --- a/crates/lash-cli/src/ui_trace.rs +++ b/crates/lash-cli/src/ui_trace.rs @@ -45,16 +45,6 @@ impl TraceRepoStatus { worktree: status.worktree.clone(), } } - - #[cfg(test)] - pub(crate) fn into_repo_status(self, cwd: &str) -> RepoStatus { - RepoStatus { - repo_root: PathBuf::from(cwd), - repo_name: self.repo_name, - branch: self.branch, - worktree: self.worktree, - } - } } impl UiTraceContext { @@ -565,7 +555,7 @@ mod tests { .expect("recorded turn activity op"); assert!(matches!( &event_op.event, - TurnEvent::AssistantProseDelta { text } if text == "world" + TurnEvent::AssistantProseDelta { text } if text.as_ref() == "world" )); assert!(matches!( trace.ops.last(), From fab9c41249fcd91f035566a45936e97d2ace0f2b Mon Sep 17 00:00:00 2001 From: Samuel Galanakis Date: Sun, 23 Aug 2026 18:39:40 +0200 Subject: [PATCH 08/25] Document and verify the facade cutover Keep the operator, end-to-end harness, and user documentation aligned with unified storage and dialect-pinned RLM sessions. Resolve the built binary through Cargo metadata so witness runs work with shared target directories. Release-Notes: Document RLM dialect selection and add TypeScript, resume-conflict, and operator coverage. --- CONTEXT.md | 4 +- README.md | 4 +- crates/lash-cli/tests/cli_e2e.rs | 75 ++++++++++++++++++++++ crates/lash-debug-cli-harness/src/lib.rs | 8 +++ crates/lash-debug-cli-harness/src/main.rs | 3 + docs/architecture.html | 12 +++- docs/export-design.html | 4 +- docs/index.html | 35 ++++------ runbooks/RULES.md | 11 ++-- runbooks/process-dock-lifecycle/runbook.md | 2 +- runbooks/resume-picker/runbook.md | 6 +- scripts/lash-operator.py | 12 +++- 12 files changed, 135 insertions(+), 41 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 4477166..41ce4f2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -10,9 +10,9 @@ - `Ctrl+C` is reserved for cancel/dismiss/quit semantics: close suggestions or overlays, cancel an active turn, clear a non-empty draft, then quit only from an idle empty prompt. - Copy uses `Ctrl+Shift+C` by default. `Ctrl+U` deletes draft text to the start of the line, `Ctrl+K` deletes to the end, and history/document scrolling uses PgUp/PgDn, mouse wheel, and scroll gestures. - `Ctrl+P` opens the command and settings palette — a searchable overlay of settings actions (theme, model, variant, and other commands) applied directly without typing a slash command. -- The status bar shows model and reasoning variant joined, then execution mode, then plugin modes, for example `gpt-5.5 medium · standard`; it carries no `lash` brand prefix. Context usage is labeled as `ctx`. +- The status bar shows model and reasoning variant joined, then execution mode and the pinned RLM dialect when applicable, for example `gpt-5.5 medium · rlm · typescript`; it carries no `lash` brand prefix. Context usage is labeled as `ctx`. - Queue previews sit directly above the input. Early-injected work is labeled `Will send in this turn`; next-turn work is labeled `Queued for next turn`. -- The `/resume` picker hides zero-turn sessions when any non-empty session exists. If only empty sessions exist it shows them with `No messages yet`; direct `/resume ` may still target any session. +- The `/resume` picker hides zero-turn sessions when any non-empty session exists. If only empty sessions exist it shows them with `No messages yet`; direct `/resume ` may still target any session. Alpha-era per-session databases add the notice `N sessions from an older Lash are not openable` and are never opened. ## Autonomous CLI Testing diff --git a/README.md b/README.md index c347cf2..5b15542 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,8 @@ cargo test --workspace cargo clippy --workspace --all-targets ``` -Lash dependencies are pinned to one exact upstream revision in the workspace -manifest. Update that revision deliberately and verify this entire workspace. +Lash dependencies are pinned to one exact commit on Lash `main`, never +to a release tag. Update that revision deliberately and verify this entire workspace. The user-facing command, configuration directory (`~/.lash`), and session format remain `lash`; splitting the repository does not rename the product or diff --git a/crates/lash-cli/tests/cli_e2e.rs b/crates/lash-cli/tests/cli_e2e.rs index 2b497af..c96ca3a 100644 --- a/crates/lash-cli/tests/cli_e2e.rs +++ b/crates/lash-cli/tests/cli_e2e.rs @@ -352,6 +352,71 @@ fn cli_interactive_pty_rlm_subagent_smoke_runs_turn_and_exits() { ); } +#[test] +fn cli_rlm_typescript_status_and_resume_conflict_are_pinned() { + let lash_home = test_lash_home("rlm-typescript-smoke"); + let mut harness = start_interactive_harness_with_dialect( + &lash_home, + ExecutionMode::Rlm, + Some("typescript"), + None, + ); + let status = harness + .wait_for_text("rlm · typescript", Duration::from_secs(15)) + .expect("wait for TypeScript status bar"); + assert!( + status.contains("rlm · typescript"), + "RLM TypeScript status was not rendered\nscreen:\n{status}" + ); + harness.finish_cleanly().expect("finish TypeScript harness"); + + let session_id = std::fs::read_dir(lash_home.path().join("sessions")) + .expect("read session roster") + .filter_map(Result::ok) + .find_map(|entry| { + entry + .file_name() + .to_str() + .and_then(|name| name.strip_suffix(".ui.json")) + .map(str::to_string) + }) + .expect("TypeScript session roster entry"); + let output = run_lash_with_timeout( + Command::new(lash_bin()) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .env("LASH_HOME", lash_home.path()) + .env("LASH_LOG", "warn") + .args([ + "--resume", + &session_id, + "--execution-mode", + "rlm", + "--rlm-dialect", + "lashlang", + "--model", + "test/cli-e2e-model", + "--print", + "This must be refused before a turn starts", + ]), + Duration::from_secs(30), + ); + let combined = format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + !output.status.success(), + "dialect conflict unexpectedly opened" + ); + assert!( + combined.contains("RLM dialect conflict") + && combined.contains("typescript") + && combined.contains("lashlang"), + "conflict must name both dialects\noutput:\n{combined}" + ); +} + #[test] fn cli_interactive_pty_rlm_uses_temporary_working_directory() { let workspace = tempfile::tempdir().expect("temp workspace"); @@ -427,6 +492,15 @@ fn start_interactive_harness( lash_home: &tempfile::TempDir, execution_mode: ExecutionMode, working_dir: Option<&std::path::Path>, +) -> LiveHarness { + start_interactive_harness_with_dialect(lash_home, execution_mode, None, working_dir) +} + +fn start_interactive_harness_with_dialect( + lash_home: &tempfile::TempDir, + execution_mode: ExecutionMode, + rlm_dialect: Option<&str>, + working_dir: Option<&std::path::Path>, ) -> LiveHarness { let repo_root = repo_root_from_manifest_dir(env!("CARGO_MANIFEST_DIR")).expect("derive repo root"); @@ -435,6 +509,7 @@ fn start_interactive_harness( config.lash_home = Some(lash_home.path().to_path_buf()); config.model = Some("test/cli-e2e-model".to_string()); config.execution_mode = execution_mode; + config.rlm_dialect = rlm_dialect.map(str::to_string); config.working_dir = working_dir.map(std::path::Path::to_path_buf); config.build_lash = false; config.timeout = Duration::from_secs(45); diff --git a/crates/lash-debug-cli-harness/src/lib.rs b/crates/lash-debug-cli-harness/src/lib.rs index 164a7b3..bcfff93 100644 --- a/crates/lash-debug-cli-harness/src/lib.rs +++ b/crates/lash-debug-cli-harness/src/lib.rs @@ -43,6 +43,7 @@ pub struct HarnessConfig { pub lash_home: Option, pub output_dir: PathBuf, pub execution_mode: ExecutionMode, + pub rlm_dialect: Option, pub model: Option, pub rows: u16, pub cols: u16, @@ -61,6 +62,7 @@ impl HarnessConfig { lash_home: None, output_dir: default_output_dir(), execution_mode: ExecutionMode::Standard, + rlm_dialect: None, model: None, rows: 40, cols: 120, @@ -130,6 +132,7 @@ pub struct SnapshotPaths { struct HarnessMetadata<'a> { success: bool, execution_mode: ExecutionMode, + rlm_dialect: Option<&'a str>, model: Option<&'a str>, working_dir: Option<&'a Path>, lash_home: Option<&'a Path>, @@ -467,6 +470,10 @@ fn lash_command_args(config: &HarnessConfig, artifacts: &HarnessArtifacts) -> Ve "--debug-ui-trace-interval-ms".to_string(), config.snapshot_interval.as_millis().to_string(), ]); + if let Some(dialect) = &config.rlm_dialect { + args.push("--rlm-dialect".to_string()); + args.push(dialect.clone()); + } args } @@ -686,6 +693,7 @@ fn write_metadata( let metadata = HarnessMetadata { success, execution_mode: config.execution_mode, + rlm_dialect: config.rlm_dialect.as_deref(), model: config.model.as_deref(), working_dir: config.working_dir.as_deref(), lash_home: config.lash_home.as_deref(), diff --git a/crates/lash-debug-cli-harness/src/main.rs b/crates/lash-debug-cli-harness/src/main.rs index 9384ffc..0e03cd9 100644 --- a/crates/lash-debug-cli-harness/src/main.rs +++ b/crates/lash-debug-cli-harness/src/main.rs @@ -15,6 +15,8 @@ struct Cli { #[arg(long, value_enum, default_value_t = ExecutionModeArg::Standard)] execution_mode: ExecutionModeArg, #[arg(long)] + rlm_dialect: Option, + #[arg(long)] repo_root: Option, #[arg(long)] lash_bin: Option, @@ -65,6 +67,7 @@ fn main() -> Result<()> { let mut config = HarnessConfig::new(repo_root); config.execution_mode = cli.execution_mode.into(); + config.rlm_dialect = cli.rlm_dialect; config.lash_bin = cli.lash_bin; config.lash_home = cli.lash_home; config.working_dir = cli.working_dir; diff --git a/docs/architecture.html b/docs/architecture.html index 298e997..cb0812b 100644 --- a/docs/architecture.html +++ b/docs/architecture.html @@ -50,15 +50,21 @@

Ownership boundary

arguments + ~/.lash/config.json
               │
               ▼
-provider factories + plugin factories + store
+host provider config + plugin factories + host policies
               │
               ▼
-        LashCore / LashSession
+LashCore::{standard,rlm}_builder
+  + ~/.lash/store/durable-core.db catalog
+  + process / trigger / effect / artifact / env stores
+  + attachment store + trace sink
+              │
+              ▼
+         LashSession facade
               │
        ┌──────┴────────┐
        ▼               ▼
 interactive TUI   --print / JSON / RPC
-

Execution modes such as standard and rlm are Lash protocol plugins. The CLI chooses and configures one for a session; it does not implement protocol semantics itself.

+

Execution modes such as standard and rlm are Lash protocol plugins. The CLI chooses and configures one for a session; it does not implement protocol semantics itself. Every session lives in one Lash catalog under ~/.lash/store/; the CLI keeps only display metadata in its host roster. Store preflight runs before provider or session startup, and shutdown flushes the trace sink before closing each core.

Dependency rule

diff --git a/docs/export-design.html b/docs/export-design.html index fc7e016..95464d4 100644 --- a/docs/export-design.html +++ b/docs/export-design.html @@ -1585,8 +1585,8 @@

same shape · everywhere

const backBtn = document.getElementById('back-btn'); // Tree shape — the whole call graph for breadcrumb resolution. - // In the real exporter this comes from walking sessions/*.db and - // matching parent_session_id chains. + // In the real exporter this comes from the durable-core catalog's + // session read views and their parent-session relationships. const tree = { root: { label: 'root', sid: 's1·a47e', kind: 'root', parent: null }, auth_files: { label: 'auth_files', sid: 's2·b9c1', kind: 'subagent', parent: 'root' }, diff --git a/docs/index.html b/docs/index.html index 66e322f..35fce15 100644 --- a/docs/index.html +++ b/docs/index.html @@ -29,14 +29,15 @@

using/cli

First Run

-

If ~/.lash/config.json does not exist, lash launches the setup wizard. The wizard provisions an active provider, collects credentials, and writes the config file before dropping you into the TUI.

+

If ~/.lash/config.json does not exist, lash launches the setup wizard. The wizard provisions an active provider, collects credentials, asks for the default execution mode and RLM dialect, and writes the config file before dropping you into the TUI.

$ lash
 # Setup wizard:
 # 1. Choose a provider (Anthropic, OpenAI, OpenAI-compatible, Codex, Google OAuth)
 # 2. Paste API key (or run OAuth / device-code flow)
 # 3. Optionally paste a Tavily API key for the built-in web-search tool
-# 4. Drop into the interactive TUI
+# 4. Choose standard or RLM; if RLM, choose lashlang or typescript +# 5. Drop into the interactive TUI

Re-run the wizard at any time with lash --provider. Use /provider from inside a session to switch providers without restarting.

@@ -58,7 +59,7 @@

Slash Commands

/info/status/infoShow current session/runtime info /model—/model [name]Show or switch the LLM model /variant—/variant [name]Show or switch the model variant (e.g. high, max) - /mode—/mode [name]Show the session's execution mode (standard / rlm); it is locked for the session, so changing it requires a new session started with --execution-mode + /mode—/modeShow the session's locked execution mode and, for RLM, its pinned dialect (for example rlm · typescript) /provider/login/providerSwitch, add, or re-authenticate providers /logout—/logoutRemove stored credentials for active provider /retry—/retryReplay the previous turn payload @@ -106,7 +107,7 @@

Selection & clipboard

Command palette

-
Ctrl+P
Open the command palette settings overlay: a searchable list of Settings actions (theme, model, variant, provider, execution mode, logout) and Session actions (new, resume, tree, fork), each annotated with the equivalent slash command.
+
Ctrl+P
Open the command palette settings overlay. In RLM mode, each New Session action names the dialect that will be pinned to the new session.
@@ -150,7 +151,7 @@

Provider & model

Execution

--execution-mode <mode>
standard (default) or rlm.
-
-c, --context-approach <approach>
For standard mode: rolling_history or observational_memory.
+
--rlm-dialect <dialect>
Pin lashlang or typescript for the lifetime of a new RLM session. Refused in standard mode.
--rlm-var name=<json>
Repeatable. Project a read-only bound variable into the RLM autonomous turn.
--rlm-vars-file <path>
Same as above but load the projections from a JSON object file.
--rlm-termination <mode>
Choose natural (default) or require an explicit structured finish with finish-required.
@@ -159,7 +160,7 @@

Execution

Session

-
--resume <id_or_name>
Resume by session id, name, or legacy .db filename.
+
--resume <id_or_name>
Resume by session id or name. Alpha-era per-session .db files are listed as incompatible and refused with their preflight schema stamps; they are never opened or deleted.
--resume-prompt <prompt>
Send a prompt right after resume.
@@ -181,26 +182,13 @@

Debug & tracing

--debug-ui-trace-interval-ms <ms>
When recording a UI trace, also capture numbered checkpoint snapshots at the given interval.
-
-

Observational memory

-
-
--om-observation-message-tokens <tokens>
Observe recent raw history after this many new message tokens.
-
--om-observation-buffer-tokens <tokens>
Keep this much message-token slack before observation pressure starts.
-
--om-observation-block-after-tokens <tokens>
Block new turns once unobserved message tokens pass this threshold.
-
--om-observation-max-tokens-per-batch <tokens>
Cap the raw-history slice sent to one observer pass.
-
--om-previous-observer-tokens <tokens>
Budget previous observer notes included in a fresh observation pass.
-
--om-reflection-observation-tokens <tokens>
Target observation-token volume before reflection becomes useful.
-
--om-reflection-buffer-activation-percent <percent>
Start reflection when the reflection buffer reaches this percentage.
-
--om-reflection-block-after-tokens <tokens>
Block new turns once pending reflection material passes this threshold.
-
-

Admin

-
--reset
Delete ~/.lash/ and ~/.cache/lash/ and exit.
+
--reset
Delete ~/.lash/store/ and the host session roster under ~/.lash/sessions/, preserving provider configuration.
--info
Print current config / runtime info and exit.
--check-update / --update
Check / install the latest release.
-
--export <db>
Export a persisted session database. Combine with --export-format html|json, --export-trace <jsonl>, and --export-out <path>.
+
--export <session-id>
Export a session from the unified Lash catalog. Combine with --export-format html|json, --export-trace <jsonl>, and --export-out <path>.
@@ -245,6 +233,8 @@

Config File

{
   "active_provider": "openai",
+  "execution_mode": "rlm",
+  "rlm_dialect": "typescript",
   "providers": {
     "openai": { "type": "openai", "api_key": "sk-..." },
     "anthropic": {
@@ -277,6 +267,8 @@ 

Config File

active_providerstringKey of the currently active provider; must exist in providers. providersmapPer-provider credentials and metadata, keyed by provider kind. Shared provider tuning lives under options, including cache_retention (none | short | long) and max_output_tokens. + execution_modestringDefault mode for fresh sessions: standard or rlm. + rlm_dialectstringDefault language pinned to fresh RLM sessions: lashlang or typescript. auxiliary_secretsobjectSecrets for non-LLM services. Currently tavily_api_key. mcp_serversmapMCP server configs by name. Discriminated by transport: stdio, streamable_http, or sse. agent_modelsmapUser-overridable model names per subagent capability. @@ -302,6 +294,7 @@

Environment Variables

LASH_GOOGLE_CLIENT_ID / LASH_GOOGLE_CLIENT_SECRETOverride the OAuth client credentials used by the Google / Code Assist provider. CODE_ASSIST_ENDPOINT / CODE_ASSIST_API_VERSIONOverride the Google Code Assist API endpoint and version for provider integration testing. LASH_COPY_BINDINGOverride the copy keystroke: ctrl-shift-c (default) or ctrl-y. Ctrl+C stays reserved for cancel semantics, so ctrl-c falls back to the default. + LASH_RUNBOOK_DIALECTDefault RLM dialect when an older config file does not yet declare rlm_dialect. LASH_LOGtracing-subscriber log filter (e.g. debug, warn, lash=trace). LASH_NO_OSC52Disable OSC 52 clipboard escape sequences. LASH_INSTALL_DIROverride the install dir used by --update. diff --git a/runbooks/RULES.md b/runbooks/RULES.md index 81d66ec..d8ff8cb 100644 --- a/runbooks/RULES.md +++ b/runbooks/RULES.md @@ -50,7 +50,8 @@ binary in a child PTY and reads line commands from stdin or `--script FILE`. `rlm-subagent-smoke` / `rlm-workspace-smoke` / `rlm-nonzero-exit-smoke` (need `-- -em rlm`). - `--no-build` launches the existing `target/debug/lash` instead of rebuilding. - `--lash-home DIR` fixes `LASH_HOME` so sessions and artifacts **persist across runs** - (sessions live in `DIR/sessions/*.db`). Omit it for a throwaway temp home. + (the catalog lives in `DIR/store/durable-core.db`; picker metadata lives in + `DIR/sessions/*.ui.json`). Omit it for a throwaway temp home. - `--trace PATH` passes `--debug-ui-trace PATH` to lash: a replayable UI-trace JSON plus a final snapshot — the same on-disk artifact the PTY smoke test asserts on. - `-- ` — everything after `--` is forwarded to `lash` (e.g. `-- -em rlm`). @@ -90,7 +91,8 @@ it). Turns, tool calls, subagent spawns, and queued-turn dispatch are all async and render over several frames. Gate on the **rendered outcome** with `expect ` — never `wait -N`. Submit a turn, then `expect `; the footer status walks +N`. Submit a turn, then `expect `; the footer status includes +the locked mode and dialect (for example `rlm · typescript`) and walks `Idle → Working → Thinking → Responding → Idle` (and `Running tool · ` for tool calls), so gate on the terminal render, not a fixed sleep. `wait` is only for letting the render settle before a `screen`, or spacing session-file writes so timestamp filenames @@ -110,8 +112,9 @@ Prefer an objective signal over eyeballing. In order of authority: independent of what rendered: ops like `user_turn`, `queue_current_turn_input` (Early Injection), `queue_turn` (Next Full Turn). Use it to prove intent-vs-render (e.g. Enter mid-turn recorded exactly one `queue_current_turn_input` and zero `queue_turn`). -3. **On-disk state** — `LASH_HOME/sessions/*.db` (durable session evidence; message counts - drive the resume picker), and `LASH_HOME/test-provider-requests.jsonl` (the visible user +3. **On-disk state** — `LASH_HOME/store/durable-core.db` (durable session evidence), + `LASH_HOME/sessions/*.ui.json` (host roster metadata that drives the picker), and + `LASH_HOME/test-provider-requests.jsonl` (the visible user texts each provider call actually saw — objective proof of what reached the model, and how many times). diff --git a/runbooks/process-dock-lifecycle/runbook.md b/runbooks/process-dock-lifecycle/runbook.md index 6602fec..68b0acc 100644 --- a/runbooks/process-dock-lifecycle/runbook.md +++ b/runbooks/process-dock-lifecycle/runbook.md @@ -100,7 +100,7 @@ tokens — deliberate). 1. **Start a process.** Drive a turn whose tool starts a durable background process. Gate: the `Background` dock renders a row `◆ running · ·
-

Execution modes such as standard and rlm are Lash protocol plugins. The CLI chooses and configures one for a session; it does not implement protocol semantics itself. Every session lives in one Lash catalog under ~/.lash/store/; the CLI keeps only display metadata in its host roster. Store preflight runs before provider or session startup, and shutdown flushes the trace sink before closing each core.

+

Execution modes such as standard and rlm are Lash protocol plugins. The CLI chooses and configures one for a session; it does not implement protocol semantics itself. Every session lives in one Lash catalog under ~/.lash/store/; listing, resume, startup, and tree discovery take durable identity from that catalog, while the CLI host roster supplies display metadata only. Catalog-only sessions remain addressable with fallback labels. Store preflight runs before provider or session startup, and shutdown flushes the trace sink before closing each core. This pin reads session schema 41, trace schema 9, and remote protocol 46.

Dependency rule

diff --git a/docs/export-design.html b/docs/export-design.html index 95464d4..cd2f177 100644 --- a/docs/export-design.html +++ b/docs/export-design.html @@ -1572,7 +1572,7 @@

breadcrumb · always visible

same shape · everywhere

-

Every view is just a session render. The exporter already does that for one .db; we just iterate per descendant session, link them with the breadcrumb, and bake them all into one self-contained html file.

+

Every view is just a session render. The exporter reads each descendant from the unified session catalog, links them with the breadcrumb, and bakes them all into one self-contained html file.

diff --git a/docs/export.html b/docs/export.html index 39a1296..e6b0f2d 100644 --- a/docs/export.html +++ b/docs/export.html @@ -167,7 +167,7 @@

Implementation map

  1. Tree discovery

    -

    Given a root .db path and a trace JSONL, tree::load_tree_from_paths scans the sessions directory, keeps every child store reachable through parent_session_id chains, and partitions LlmPromptSnapshots by context.session_id. Child sessions are classified from persisted SessionRelation::Child metadata and anchored back to the parent's spawn_agent tool call when possible. continue_as no longer participates in tree discovery because its AgentFrame records live inside the same session store.

    +

    Given the unified store root, a root session id, and a trace JSONL, tree::load_tree_from_paths enumerates the durable session catalog, keeps every child reachable through persisted parent relationships, and partitions LlmPromptSnapshots by context.session_id. Session content is opened read-only; the narrowly retained mutable metadata path supplies the full child relation needed to anchor a child back to its parent's spawn_agent tool call. continue_as never participates in tree discovery because its AgentFrame records live inside the same session.

  2. View topology

    diff --git a/docs/index.html b/docs/index.html index 35fce15..c594012 100644 --- a/docs/index.html +++ b/docs/index.html @@ -309,7 +309,8 @@

    Environment Variables

    Resume & Fork

    -

    Every session has a UUID and a generated human-readable name. --resume <id_or_name> accepts either, plus the legacy .db filename for old sessions. Without an argument, /resume opens an interactive picker showing the 50 most recent sessions sorted by recency. The picker hides zero-turn sessions when any non-empty session exists; direct /resume <id-or-name> may still target any session.

    +

    Every session has a UUID and normally a generated human-readable name. --resume <id_or_name> accepts either; legacy .db filenames are refused as incompatible. Without an argument, /resume opens an interactive picker showing the 50 most recent live root sessions from Lash's unified catalog, sorted by recency. The host roster contributes display metadata only, so catalog-only sessions remain visible with fallback labels. The picker hides zero-turn sessions when any non-empty session exists; direct /resume <id-or-name> may still target any session.

    +

    The current durable compatibility markers are session schema 41, trace schema 9, and remote protocol 46. lash --version and /info report them.

    /fork branches the current session at the current message and opens the fork in a new terminal window. Lash resolves the launcher from the running executable and falls back to finding lash on $PATH if that binary isn't launchable. The launched fork runs lash --resume <new-session-id> in the new terminal.

    diff --git a/runbooks/quickstart-walkthrough/runbook.md b/runbooks/quickstart-walkthrough/runbook.md index 65d5164..46af739 100644 --- a/runbooks/quickstart-walkthrough/runbook.md +++ b/runbooks/quickstart-walkthrough/runbook.md @@ -17,9 +17,9 @@ source is `crates/lash` · `crates/lash-provider-openai`, and its "steps" are Ru keystrokes. There is no CLI walkthrough on the page to type into the operator. So this runbook gates the page in the two forms its claims actually take: -- **Structural / API claims** (crate names, version pins, the code snippet, the API - surface) → machine-verified evidence in the Lash repository (`scripts/lint_docs.py` + a - Cargo.toml cross-check). +- **Structural / API claims** (crate names, the code snippet, the API surface) → + machine-verified evidence in the Lash repository: docs lint must pass and the checked-in + quickstart snippet must compile against the façade API pinned by this workspace. - **The one runtime visible-behavior claim** ("run one turn; read the settled assistant message", `println!("{prose}")`) → reproduced against the real `lash` binary through the operator: one turn in → one settled assistant message out. @@ -32,7 +32,7 @@ operator surface. That is a scope clarification to record, not a page defect. 1. **Every visible claim is a gate.** No claim gets a pass on trust — each maps to `lint_docs`, a Cargo.toml value, or a rendered operator outcome. -2. **A mismatch is reported, never fixed.** If a version pin, a crate name, the snippet, or +2. **A mismatch is reported, never fixed.** If a crate name, the snippet, or the one-turn behavior diverges from the page, RCA it and stop — do not reconcile by editing docs or code. 3. **Don't over-claim the analogue.** The operator round-trip stands in for the compiled @@ -50,14 +50,12 @@ and `Cargo.toml` exist there; confirm the operator exists in this repository. | # | Page claim | Gate | Expected | |---|-----------|------|----------| | 1 | Crate is published as `lash-runtime`; the Rust import stays `use lash::...` | Lash repository `Cargo.toml` alias | `lash = { package = "lash-runtime", … }` | -| 2 | `lash-runtime = "=0.1.0-alpha.81"` and `lash-provider-openai = "=0.1.0-alpha.81"` | Lash repository `Cargo.toml` workspace `version` | matches (`0.1.0-alpha.81`) | -| 3 | The "Minimal Example" code (`standard_builder → provider → model(from_token_limits) → effect_host(InlineEffectHost) → attachment_store(InMemoryAttachmentStore) → build`; `core.session(..).open()`; `session.turn(TurnInput::text(..)).run()`; `result.assistant_message()`) | In the Lash repository, `python3 scripts/lint_docs.py` (the page's `data-snippet="quickstart#hello-lash"` is diffed against `examples/docs-snippets/src/quickstart.rs`) | `docs lint: ok` | +| 2 | The "Minimal Example" code (`standard_builder → provider → model(from_token_limits) → effect_host(InlineEffectHost) → attachment_store(InMemoryAttachmentStore) → build`; `core.session(..).open()`; `session.turn(TurnInput::text(..)).run()`; `result.assistant_message()`) | In the Lash repository, `python3 scripts/lint_docs.py` (the page's `data-snippet="quickstart#hello-lash"` is diffed against `examples/docs-snippets/src/quickstart.rs`) and the snippet crate's compile check against the pinned façade | `docs lint: ok`; snippet compiles against the pinned façade API | Run `python3 scripts/lint_docs.py` in the Lash repository — a clean `docs lint: ok` is the objective gate that the snippet and page structure haven't drifted from the compiled -snippet source. Cross-check claims 1–2 against that repository's `Cargo.toml` directly. -**Any divergence here is a docs bug → report/RCA, don't fix.** (At time of authoring: all -three pass — the version pins match the workspace, so no drift bug to report.) +snippet source. Then run the snippet crate's documented compile check at the Lash revision +pinned by this workspace. **Any divergence here is a docs bug → report/RCA, don't fix.** ## Phase 2 — The one runtime visible-behavior claim (real binary, drivable) @@ -89,12 +87,11 @@ page's central visible-behavior claim is wrong → report/RCA (do not fix). | Item | Objective gate | Verdict | Notes | |------|----------------|---------|-------| | Crate-name / import claim | Lash repository `Cargo.toml` `package = "lash-runtime"` | | | -| Version pins match workspace | `=0.1.0-alpha.81` == workspace `version` | | | -| Snippet / API surface un-drifted | Lash repository `scripts/lint_docs.py` → `docs lint: ok` | | | +| Snippet / API surface un-drifted | Lash repository `scripts/lint_docs.py` → `docs lint: ok`; quickstart snippet compiles against the pinned façade API | | | | One-turn settled-message behavior | one turn → one `■` settled message | | | | Scope framing recorded | page is a library quickstart, not a CLI walkthrough | | | -**Aggregate:** do the page's structural claims match the code (crate name, version pins, +**Aggregate:** do the page's structural claims match the code (crate name and snippet), does the real binary honor its one runtime claim (one turn → one settled message), and is the library-vs-CLI scope of the page reported rather than papered over. Any concrete divergence is surfaced as a docs bug, never edited away. diff --git a/runbooks/resume-picker/runbook.md b/runbooks/resume-picker/runbook.md index fd114e0..937aace 100644 --- a/runbooks/resume-picker/runbook.md +++ b/runbooks/resume-picker/runbook.md @@ -54,13 +54,13 @@ expect 10 Started new session ``` You are now in an **empty** session (call it B) with the non-empty session A persisted. Read -B's on-disk identity before rolling past it — open `/info` and record the `session db` -path's basename (`.db`) from the **Paths** section: +B's catalog identity before rolling past it — open `/info` and record `session id: ` +from the **Session** section: ``` type /info key enter -expect 10 session db +expect 10 session id screen 40 key esc ``` @@ -95,25 +95,26 @@ violation → Abort/RCA. ## Phase 3 — Direct-target escape hatch -Target the hidden empty session B directly by its `.db` filename (from Phase 1): +Target the hidden empty session B directly by its catalog id (from Phase 1): ``` clear -type /resume .db +type /resume key enter -expect 10 Resumed: .db +expect 10 Resumed: ``` -Gate: `Resumed: .db` — the picker suppressed B, but the direct form reached it. +Gate: `Resumed: ` — the picker suppressed B, but the direct form reached it. **Rigor (contract check on the identifier surface).** CONTEXT.md → "Operator UI" and `docs/index.html` present `/resume ` as accepting the session **id or -human-readable name** too, not only the `.db` filename. As a rigor case, also drive +human-readable name**. As a rigor case, also drive `/resume ` (the `name` shown by `/info`). If it returns `Could not resolve session ...`, that is a divergence between the documented identifier surface and the CLI — **report it as a finding** (RCA it to the resume-identifier resolution path), and do **not** -edit the doc or code to hide it. Filename resolution passing is the escape-hatch gate; -name/id resolution is the rigor case. +edit the doc or code to hide it. Catalog-id resolution is the escape-hatch gate; name +resolution is the rigor case. Legacy per-session database filename targets are intentionally +refused. ## Phase 4 — Only-empty store @@ -147,7 +148,7 @@ thing to resume. Then `kill`. |------|----------------|---------|-------| | Hides empty when non-empty exists | `Resume Session (1/1)`, only `hello from pty`, no `No messages yet` | | | | Current session excluded | current session absent from the list | | | -| Direct target reaches hidden session | `Resumed: .db` | | | +| Direct target reaches hidden session | `Resumed: ` | | | | Only-empty shows all | every row `No messages yet` | | | | Identifier surface (rigor) | name/id resolves, or reported as a doc/CLI divergence | | | From 21d32dc3e0dbc48c9dcd52f6ec71ba9613a01950 Mon Sep 17 00:00:00 2001 From: Samuel Galanakis Date: Tue, 25 Aug 2026 11:29:00 +0200 Subject: [PATCH 15/25] Clear cancelled turn previews immediately Remove dropped active-turn inputs from the UI snapshot as soon as durable cancellation settles so restored editor text never masquerades as queued next-turn work. Release-Notes: Prevent cancelled steer text from briefly appearing as queued work after Escape. --- .../lash-cli/src/interactive/input_handling/shortcuts.rs | 7 +++++++ crates/lash-cli/tests/cli_e2e.rs | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/lash-cli/src/interactive/input_handling/shortcuts.rs b/crates/lash-cli/src/interactive/input_handling/shortcuts.rs index 2f40aa3..317f217 100644 --- a/crates/lash-cli/src/interactive/input_handling/shortcuts.rs +++ b/crates/lash-cli/src/interactive/input_handling/shortcuts.rs @@ -259,6 +259,13 @@ fn restore_cancelled_input_texts( session_id: &str, outcome: &lash::TurnCancelInputOutcome, ) { + let dropped_input_ids = outcome + .affected_inputs + .iter() + .filter(|input| matches!(input.disposition, lash::TurnCancelDisposition::Drop)) + .map(|input| input.input_id.clone()) + .collect::>(); + app.remove_pending_turn_inputs(&dropped_input_ids); let texts = cancelled_input_texts(outcome); let Some(restored) = prepend_cancelled_input_texts(app.input(), &texts) else { return; diff --git a/crates/lash-cli/tests/cli_e2e.rs b/crates/lash-cli/tests/cli_e2e.rs index d597482..3dd9d4b 100644 --- a/crates/lash-cli/tests/cli_e2e.rs +++ b/crates/lash-cli/tests/cli_e2e.rs @@ -166,7 +166,8 @@ fn cli_interactive_pty_active_steer_escape_restores_editor_without_replay() { assert!( restored_screen.contains("Manually interrupted.") && restored_screen.contains("queued after escape") - && !restored_screen.contains("test-provider echo: queued after escape"), + && !restored_screen.contains("test-provider echo: queued after escape") + && !restored_screen.contains("Queued for next turn"), "screen should show the interrupted turn and restored, unsent steer\nscreen:\n{restored_screen}" ); harness From addc00cffb433f7fac52bba6781c3b4d630fa9c5 Mon Sep 17 00:00:00 2001 From: Samuel Galanakis Date: Tue, 25 Aug 2026 12:18:29 +0200 Subject: [PATCH 16/25] Fix durable Escape cancellation Track the physical Lash turn identity for direct and queued work, submit cancellation without waiting in the TUI, and restore dropped input from the settled turn report. Remove the misleading recovery sidecar and derive the displayed session schema from one documented compatibility constant. Release-Notes: Keep Escape responsive during durable cancellation and restore dropped queued input after settlement. --- CONTEXT.md | 2 +- crates/lash-cli/src/app/live.rs | 14 ++ crates/lash-cli/src/app/mod.rs | 3 + crates/lash-cli/src/autonomous.rs | 2 +- crates/lash-cli/src/event.rs | 11 +- crates/lash-cli/src/info.rs | 8 +- .../src/interactive/input_handling/mod.rs | 2 + .../interactive/input_handling/shortcuts.rs | 31 ++- .../src/interactive/input_handling/turns.rs | 9 +- crates/lash-cli/src/interactive/mod.rs | 12 + crates/lash-cli/src/interactive/runtime.rs | 6 +- crates/lash-cli/src/resume.rs | 14 -- crates/lash-cli/src/session_log.rs | 33 --- crates/lash-cli/src/startup/session.rs | 18 +- crates/lash-cli/src/turn_runner.rs | 228 +++++++++++++++++- crates/lash-cli/tests/cli_e2e.rs | 80 +++++- 16 files changed, 374 insertions(+), 99 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index ec33da5..776fe39 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -12,7 +12,7 @@ - `Ctrl+P` opens the command and settings palette — a searchable overlay of settings actions (theme, model, variant, and other commands) applied directly without typing a slash command. - The status bar shows model and reasoning variant joined, then execution mode and the pinned RLM dialect when applicable, for example `gpt-5.5 medium · rlm · typescript`; it carries no `lash` brand prefix. Context usage is labeled as `ctx`. - Queue previews sit directly above the input. Early-injected work is labeled `Will send in this turn`; next-turn work is labeled `Queued for next turn`. -- Escape during an active turn durably cancels that exact turn with dropped-input disposition, restores affected steer text ahead of the current editor draft, and never replays it automatically on a later turn. A resumed session surfaces any dropped text that could not be restored before exit as a system message. +- Escape during an active turn durably cancels the physical turn ID learned from the queued/direct turn builder's turn-scoped activity sink with dropped-input disposition, then restores affected steer text from the completed `TurnReport` ahead of the current editor draft without replaying it automatically. A crash during the narrow editor-restore window may lose text the operator explicitly chose to Drop; no recovery sidecar is kept. - The `/resume` picker discovers live root sessions from Lash's unified catalog and uses the host roster only for display metadata. It hides zero-turn sessions when any non-empty session exists. If only empty sessions exist it shows them with `No messages yet`; catalog-only sessions receive fallback labels, and direct `/resume ` may still target any session. Alpha-era per-session databases add the notice `N sessions from an older Lash are not openable` and are never opened. - The current durable compatibility markers are session schema 41, trace schema 9, and remote protocol 46. `lash --version` and `/info` report these markers. diff --git a/crates/lash-cli/src/app/live.rs b/crates/lash-cli/src/app/live.rs index 64cc580..3dc2537 100644 --- a/crates/lash-cli/src/app/live.rs +++ b/crates/lash-cli/src/app/live.rs @@ -96,6 +96,7 @@ impl App { pub fn start_turn(&mut self) { self.foreground_turn_active = true; + self.active_turn_id = None; self.run_state = CliRunState::Working; self.manual_interrupt_requested = false; self.pending_retry_status = None; @@ -131,6 +132,14 @@ impl App { self.foreground_turn_active } + pub(crate) fn set_active_turn_id(&mut self, turn_id: impl Into) { + self.active_turn_id = Some(turn_id.into()); + } + + pub(crate) fn active_turn_id(&self) -> Option<&str> { + self.active_turn_id.as_deref() + } + pub(crate) fn can_inject_into_active_turn(&self) -> bool { self.foreground_turn_active && self.run_state.is_injectable_runtime_phase() @@ -154,6 +163,7 @@ impl App { pub fn stop_turn(&mut self) { self.invalidate_live_reasoning_tail(); self.foreground_turn_active = false; + self.active_turn_id = None; self.pending_retry_status = None; self.active_ui_turn_ordinal = None; self.next_lashlang_block_ordinal = 0; @@ -216,6 +226,7 @@ impl App { pub(super) fn clear_status(&mut self) { self.foreground_turn_active = false; + self.active_turn_id = None; self.manual_interrupt_requested = false; self.pending_retry_status = None; self.live.turn = None; @@ -227,6 +238,9 @@ impl App { return; } self.foreground_turn_active = active; + if !active { + self.active_turn_id = None; + } if !active && self.run_state.is_runtime_active() { let keep_transient_error = self.live.turn.as_ref().is_some_and(|turn| { turn.run_state == CliRunState::Error diff --git a/crates/lash-cli/src/app/mod.rs b/crates/lash-cli/src/app/mod.rs index 343a3cd..47e7111 100644 --- a/crates/lash-cli/src/app/mod.rs +++ b/crates/lash-cli/src/app/mod.rs @@ -657,6 +657,8 @@ pub struct App { lashlang_block_anchors: HashMap, /// Set only when this local UI requested cancellation via Esc. manual_interrupt_requested: bool, + /// Physical Lash turn identity learned from the builder's turn-scoped activity sink. + active_turn_id: Option, /// Retry details to keep visible once the retry request is in flight. pending_retry_status: Option, /// Current text selection state. @@ -911,6 +913,7 @@ impl App { next_lashlang_block_ordinal: 0, lashlang_block_anchors: HashMap::new(), manual_interrupt_requested: false, + active_turn_id: None, pending_retry_status: None, selection: TextSelection::default(), toast: None, diff --git a/crates/lash-cli/src/autonomous.rs b/crates/lash-cli/src/autonomous.rs index e9d8011..5fc8e4c 100644 --- a/crates/lash-cli/src/autonomous.rs +++ b/crates/lash-cli/src/autonomous.rs @@ -365,7 +365,7 @@ async fn run_autonomous_turn( let observable = session.observe(); let cursor = observable.current_observation().cursor; let mut observation = Some(observable.subscribe_and_recover(cursor)); - let (cancel, return_rx) = spawn_session_turn(session, turn_input, stream_id); + let (cancel, return_rx) = spawn_session_turn(session, turn_input, stream_id, None); #[cfg(unix)] { let cancel = cancel.clone(); diff --git a/crates/lash-cli/src/event.rs b/crates/lash-cli/src/event.rs index d8858a9..5cc7ebf 100644 --- a/crates/lash-cli/src/event.rs +++ b/crates/lash-cli/src/event.rs @@ -21,6 +21,10 @@ pub enum AppEvent { stream_id: u64, activity: Box, }, + ActiveTurnObserved { + stream_id: u64, + turn_id: String, + }, Prompt { request: PromptRequest, response_tx: std::sync::mpsc::Sender, @@ -173,9 +177,10 @@ impl AppEventTx { fn lane_for_event(event: &AppEvent) -> EventLane { match event { - AppEvent::Terminal(_) | AppEvent::ClipboardImageReady { .. } | AppEvent::Quit => { - EventLane::High - } + AppEvent::Terminal(_) + | AppEvent::ClipboardImageReady { .. } + | AppEvent::ActiveTurnObserved { .. } + | AppEvent::Quit => EventLane::High, AppEvent::Session { .. } | AppEvent::ProcessChanged { .. } | AppEvent::Prompt { .. } diff --git a/crates/lash-cli/src/info.rs b/crates/lash-cli/src/info.rs index 8a73fed..7d78e1e 100644 --- a/crates/lash-cli/src/info.rs +++ b/crates/lash-cli/src/info.rs @@ -12,6 +12,9 @@ use crate::keybindings::shortcut_help_rows; use crate::model_selection::provider_display_label; use crate::overlay::{DocumentRow, DocumentSection, DocumentState}; +// Mirrors the private `lash_sqlite_store::schema::SCHEMA_VERSION` at the pinned Lash revision. +const SESSION_SCHEMA: u32 = 41; + pub(crate) fn version_text() -> String { format!( "lash-cli {} @@ -23,7 +26,7 @@ durable formats: {}", fn durable_formats_text() -> String { let mut formats = vec![ - "session schema=41".to_string(), + format!("session schema={SESSION_SCHEMA}"), format!("trace schema={}", lash_trace::TRACE_SCHEMA_VERSION), format!("remote protocol={}", lash::remote::REMOTE_PROTOCOL_VERSION), ]; @@ -312,9 +315,10 @@ mod tests { fn durable_format_versions_match_the_adopted_lash_pin() { assert_eq!(lash_trace::TRACE_SCHEMA_VERSION, 9); assert_eq!(lash::remote::REMOTE_PROTOCOL_VERSION, 46); + let expected_session_schema = format!("session schema={SESSION_SCHEMA}"); assert!( durable_formats_text() - .starts_with("session schema=41, trace schema=9, remote protocol=46") + .starts_with(&format!("{expected_session_schema}, trace schema=")) ); } use crate::keybindings::ShortcutHelpRow; diff --git a/crates/lash-cli/src/interactive/input_handling/mod.rs b/crates/lash-cli/src/interactive/input_handling/mod.rs index 98fe558..52fd06b 100644 --- a/crates/lash-cli/src/interactive/input_handling/mod.rs +++ b/crates/lash-cli/src/interactive/input_handling/mod.rs @@ -11,6 +11,8 @@ mod mouse; mod shortcuts; mod turns; +pub(crate) use shortcuts::restore_cancelled_input_texts; + use std::sync::Arc; use std::sync::atomic::AtomicBool; diff --git a/crates/lash-cli/src/interactive/input_handling/shortcuts.rs b/crates/lash-cli/src/interactive/input_handling/shortcuts.rs index 317f217..ff283b8 100644 --- a/crates/lash-cli/src/interactive/input_handling/shortcuts.rs +++ b/crates/lash-cli/src/interactive/input_handling/shortcuts.rs @@ -91,7 +91,7 @@ pub(super) async fn handle_global_shortcut_key( app.timeline .push_system_message_if_new(crate::util::manual_interrupt_message().to_string()); let process_cancel = cancel_token.take(); - cancel_active_turn_and_restore(ctx, process_cancel).await; + cancel_active_turn(ctx, process_cancel).await; return Ok(Some(false)); } if !app.input().is_empty() || app.has_pending_input_payload() { @@ -199,7 +199,7 @@ pub(super) async fn handle_global_shortcut_key( crate::util::manual_interrupt_message().to_string(), ); let process_cancel = cancel_token.take(); - cancel_active_turn_and_restore(ctx, process_cancel).await; + cancel_active_turn(ctx, process_cancel).await; } // When idle with no dialog: no-op } @@ -209,7 +209,7 @@ pub(super) async fn handle_global_shortcut_key( Ok(None) } -async fn cancel_active_turn_and_restore( +async fn cancel_active_turn( ctx: &mut SessionCtx<'_>, process_cancel: Option, ) { @@ -219,15 +219,22 @@ async fn cancel_active_turn_and_restore( } return; }; + let Some(turn_id) = ctx.app.active_turn_id().map(ToOwned::to_owned) else { + if let Some(token) = process_cancel { + token.cancel(); + } + tracing::warn!("exact turn cancellation target has not been observed"); + return; + }; let stream_id = *ctx.active_stream_id; match ctx .runtime_factory - .cancel_active_turn(&session_id, stream_id, process_cancel) + .cancel_active_turn(&session_id, &turn_id, process_cancel) .await { - Ok(outcome) => restore_cancelled_input_texts(ctx.app, &session_id, &outcome), + Ok(()) => {} Err(error) => { - tracing::warn!(stream_id, %error, "exact turn cancellation request failed"); + tracing::warn!(stream_id, turn_id, %error, "exact turn cancellation request failed"); } } } @@ -254,11 +261,7 @@ fn prepend_cancelled_input_texts(current: &str, texts: &[String]) -> Option bool { diff --git a/crates/lash-cli/src/interactive/input_handling/turns.rs b/crates/lash-cli/src/interactive/input_handling/turns.rs index fa66eec..6c3d3a5 100644 --- a/crates/lash-cli/src/interactive/input_handling/turns.rs +++ b/crates/lash-cli/src/interactive/input_handling/turns.rs @@ -506,7 +506,14 @@ async fn handle_enter_submit(ctx: &mut SessionCtx<'_>) -> anyhow::Result { ctx.app.restore_prepared_turn(queued); return Ok(false); }; - let active_turn_id = format!("cli-turn:{}", *ctx.active_stream_id); + let Some(active_turn_id) = ctx.app.active_turn_id().map(ToOwned::to_owned) else { + push_system_message( + ctx.app, + "Current-turn injection is unavailable until Lash reports the active turn identity.", + ); + ctx.app.restore_prepared_turn(queued); + return Ok(false); + }; match enqueue_prepared_turn( session, &queued, diff --git a/crates/lash-cli/src/interactive/mod.rs b/crates/lash-cli/src/interactive/mod.rs index 1863776..ee8b744 100644 --- a/crates/lash-cli/src/interactive/mod.rs +++ b/crates/lash-cli/src/interactive/mod.rs @@ -1,6 +1,7 @@ mod commands; mod helpers; mod input_handling; +pub(crate) use input_handling::restore_cancelled_input_texts; mod runtime; use std::sync::Arc; @@ -403,6 +404,7 @@ pub(crate) async fn run_app( &done.result.outcome, lash::TurnOutcome::Stopped(lash::TurnStop::Cancelled { .. }) ); + let cancel_input_outcome = done.result.cancel_input_outcome.clone(); let active_turn_had_visible_output = app .live .turn @@ -482,6 +484,7 @@ pub(crate) async fn run_app( &ui_projection_state, interrupted_message.clone(), ); + restore_cancelled_input_texts(&mut app, &cancel_input_outcome); app.clear_manual_interrupt_requested(); runtime_return_rx = None; cancel_token = None; @@ -912,6 +915,15 @@ pub(crate) async fn run_app( } apply_ui_host_effects(&mut app, ui_effects); } + AppEvent::ActiveTurnObserved { stream_id, turn_id } => { + if session_activity_is_current( + stream_id, + active_stream_id, + runtime_return_rx.is_some(), + ) { + app.set_active_turn_id(turn_id); + } + } AppEvent::Prompt { request, response_tx, diff --git a/crates/lash-cli/src/interactive/runtime.rs b/crates/lash-cli/src/interactive/runtime.rs index 7fac83b..e17bb26 100644 --- a/crates/lash-cli/src/interactive/runtime.rs +++ b/crates/lash-cli/src/interactive/runtime.rs @@ -165,6 +165,7 @@ pub(super) async fn send_user_message( ); *active_stream_id = active_stream_id.wrapping_add(1); let stream_id = *active_stream_id; + app.set_active_turn_id(format!("cli-turn:{stream_id}")); tracing::debug!( stream_id, @@ -175,7 +176,8 @@ pub(super) async fn send_user_message( ); SessionObservationBridge::spawn(&session, stream_id, app_tx.clone()); - let (cancel, return_rx) = spawn_session_turn(session, turn_input, stream_id); + let (cancel, return_rx) = + spawn_session_turn(session, turn_input, stream_id, Some(app_tx.clone())); *cancel_token = Some(cancel); *runtime_return_rx = Some(return_rx); } @@ -229,7 +231,7 @@ pub(super) async fn send_queued_work( ); SessionObservationBridge::spawn(&session, stream_id, app_tx.clone()); - let (cancel, return_rx) = spawn_session_queued_turn(session, stream_id); + let (cancel, return_rx) = spawn_session_queued_turn(session, stream_id, Some(app_tx.clone())); *cancel_token = Some(cancel); *runtime_return_rx = Some(return_rx); } diff --git a/crates/lash-cli/src/resume.rs b/crates/lash-cli/src/resume.rs index 6c65e9e..f6bde9f 100644 --- a/crates/lash-cli/src/resume.rs +++ b/crates/lash-cli/src/resume.rs @@ -36,20 +36,6 @@ pub async fn load_resumed_session( app.usage.token_usage = session.usage_report().usage.usage; app.plugin_mode_indicators = loaded.plugin_mode_indicators; app.replace_ui_activity_journal(loaded.ui_activity_journal); - match session_log::take_cancel_recovery(&loaded.session_id) { - Ok(texts) if !texts.is_empty() => { - app.timeline.push(UiTimelineItem::SystemMessage(format!( - "Inputs dropped by the last cancelled turn:\n{}", - texts.join("\n\n") - ))); - } - Ok(_) => {} - Err(error) => { - app.timeline.push(UiTimelineItem::SystemMessage(format!( - "Could not read cancelled-input recovery: {error}" - ))); - } - } app.timeline.push(UiTimelineItem::SystemMessage(format!( "Resumed: {}", loaded.filename diff --git a/crates/lash-cli/src/session_log.rs b/crates/lash-cli/src/session_log.rs index 0661a15..9fec007 100644 --- a/crates/lash-cli/src/session_log.rs +++ b/crates/lash-cli/src/session_log.rs @@ -100,10 +100,6 @@ impl SessionLogger { sessions_dir().join(format!("{session_id}.ui-activity.jsonl")) } - fn cancel_recovery_path_for(session_id: &str) -> PathBuf { - sessions_dir().join(format!("{session_id}.cancel-recovery.json")) - } - pub fn record_host_input(&self, turn: &PreparedTurn) -> Result<()> { let mut roster = load_roster_entry(&self.session_id)?; roster.message_count = roster.message_count.saturating_add(1); @@ -135,35 +131,6 @@ impl SessionLogger { } } -pub(crate) fn save_cancel_recovery(session_id: &str, texts: &[String]) -> Result<()> { - std::fs::create_dir_all(sessions_dir())?; - std::fs::write( - SessionLogger::cancel_recovery_path_for(session_id), - serde_json::to_vec_pretty(texts)?, - )?; - Ok(()) -} - -pub(crate) fn clear_cancel_recovery(session_id: &str) -> Result<()> { - match std::fs::remove_file(SessionLogger::cancel_recovery_path_for(session_id)) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error.into()), - } -} - -pub(crate) fn take_cancel_recovery(session_id: &str) -> Result> { - let path = SessionLogger::cancel_recovery_path_for(session_id); - let bytes = match std::fs::read(&path) { - Ok(bytes) => bytes, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(error) => return Err(error.into()), - }; - let texts = serde_json::from_slice(&bytes)?; - clear_cancel_recovery(session_id)?; - Ok(texts) -} - pub(crate) fn save_roster_entry(entry: &RosterEntry) -> Result<()> { std::fs::create_dir_all(sessions_dir())?; std::fs::write( diff --git a/crates/lash-cli/src/startup/session.rs b/crates/lash-cli/src/startup/session.rs index dfae8dc..cfc11d0 100644 --- a/crates/lash-cli/src/startup/session.rs +++ b/crates/lash-cli/src/startup/session.rs @@ -203,29 +203,23 @@ impl CliSessionOpener { pub(crate) async fn cancel_active_turn( &self, session_id: &str, - stream_id: u64, + turn_id: &str, process_cancel: Option, - ) -> Result { + ) -> Result<()> { let driver = self.active_core().await?.turn_work_driver(); - let address = lash::TurnAddress::new(session_id, format!("cli-turn:{stream_id}")); + let address = lash::TurnAddress::new(session_id, turn_id); let request = lash::TurnCancelRequest::new( - address.clone(), + address, format!("cli-cancel:{}", uuid::Uuid::new_v4()), Some("interactive-user".to_string()), ) .with_reason("operator requested turn cancellation") .undelivered(lash::TurnCancelDisposition::Drop); - let requested = driver.request_cancel(request.clone()).await; + let requested = driver.request_cancel(request).await.map(|_| ()); if let Some(token) = process_cancel { token.cancel(); } - requested?; - driver.await_terminal(&address).await?; - let settled = driver.request_cancel(request).await?; - Ok(settled - .record - .and_then(|record| record.outcome) - .unwrap_or_default()) + Ok(requested?) } pub(crate) async fn fork_at(&self, node_id: &str, child_session_id: &str) -> Result<()> { diff --git a/crates/lash-cli/src/turn_runner.rs b/crates/lash-cli/src/turn_runner.rs index ccf8c44..ad71c9d 100644 --- a/crates/lash-cli/src/turn_runner.rs +++ b/crates/lash-cli/src/turn_runner.rs @@ -11,8 +11,50 @@ use tokio::sync::oneshot; use tokio::task::JoinHandle; use crate::app::PreparedTurn; +use crate::event::{AppEvent, AppEventTx}; use crate::input_items::build_items_from_editor_input; +struct ActiveTurnTargetSink { + stream_id: u64, + app_tx: Option, + last_turn_id: std::sync::Mutex>, +} + +impl ActiveTurnTargetSink { + fn new(stream_id: u64, app_tx: Option) -> Self { + Self { + stream_id, + app_tx, + last_turn_id: std::sync::Mutex::new(None), + } + } +} + +#[async_trait::async_trait] +impl lash::TurnActivitySink for ActiveTurnTargetSink { + fn is_noop(&self) -> bool { + self.app_tx.is_none() + } + + async fn emit(&self, _activity: lash::TurnActivity) {} + + async fn emit_for_turn(&self, turn_id: &str, _activity: lash::TurnActivity) { + { + let mut last_turn_id = self.last_turn_id.lock().expect("turn target lock"); + if last_turn_id.as_deref() == Some(turn_id) { + return; + } + *last_turn_id = Some(turn_id.to_string()); + } + if let Some(app_tx) = &self.app_tx { + let _ = app_tx.send(AppEvent::ActiveTurnObserved { + stream_id: self.stream_id, + turn_id: turn_id.to_string(), + }); + } + } +} + /// Returned by the spawned session task after the app-owned session has been updated in place. pub(crate) struct RuntimeRunResult { pub(crate) stream_id: u64, @@ -30,6 +72,7 @@ pub(crate) fn spawn_session_turn( session: LashSession, turn_input: TurnInput, stream_id: u64, + app_tx: Option, ) -> (CancellationToken, oneshot::Receiver) { let (return_tx, return_rx) = oneshot::channel(); let cancel = CancellationToken::new(); @@ -38,6 +81,7 @@ pub(crate) fn spawn_session_turn( let return_session = session; let task = tokio::spawn(async move { + let target_sink = ActiveTurnTargetSink::new(stream_id, app_tx); tracing::debug!(stream_id, "runtime turn task spawned"); let result = match async { let turn_id = format!("cli-turn:{stream_id}"); @@ -45,7 +89,7 @@ pub(crate) fn spawn_session_turn( .turn(turn_input) .turn_id(turn_id) .cancel(task_cancel) - .stream_to(&lash::runtime::NoopTurnActivitySink) + .stream_to(&target_sink) .await } .await @@ -70,6 +114,7 @@ pub(crate) fn spawn_session_turn( pub(crate) fn spawn_session_queued_turn( session: LashSession, stream_id: u64, + app_tx: Option, ) -> (CancellationToken, oneshot::Receiver) { let (return_tx, return_rx) = oneshot::channel(); let cancel = CancellationToken::new(); @@ -78,6 +123,7 @@ pub(crate) fn spawn_session_queued_turn( let return_session = session; let task = tokio::spawn(async move { + let target_sink = ActiveTurnTargetSink::new(stream_id, app_tx); tracing::debug!(stream_id, "queued runtime turn task spawned"); let result = match async { let drain_id = format!("cli-queue-drain:{stream_id}"); @@ -85,7 +131,7 @@ pub(crate) fn spawn_session_queued_turn( .queued_turn() .drain_id(drain_id) .cancel(task_cancel) - .stream_to(&lash::runtime::NoopTurnActivitySink) + .stream_to(&target_sink) .await } .await @@ -177,3 +223,181 @@ async fn runtime_error_turn_result(session: &LashSession, message: String) -> la cancel_input_outcome: Default::default(), } } + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + use lash::TurnActivitySink as _; + + use super::*; + use crate::app::App; + use crate::event::{AppEvent, AppEventPump}; + use crate::interactive::restore_cancelled_input_texts; + + fn queued_cancel_test_core(provider: lash::provider::ProviderHandle) -> lash::LashCore { + lash::LashCore::standard_builder(lash::TurnBudget::Unbounded) + .commit_budget(lash::CommitBudget::bounded(1024 * 1024, 512)) + .queued_work_batching(lash::QueuedWorkBatchingConfig::new(1)) + .effect_host(Arc::new( + lash::durability::InlineEffectHost::default() + .allow_process_lifetime_completion_keys(), + )) + .attachment_store(Arc::new(lash::persistence::InMemoryAttachmentStore::new())) + .process_env_store(Arc::new( + lash::persistence::InMemoryProcessExecutionEnvStore::new(), + )) + .provider(provider) + .model( + lash_core::ModelSpec::builder("queued-cancel-test") + .context_window_tokens(16_384) + .build() + .expect("test model"), + ) + .store_factory(Arc::new( + lash_core::facade_support::InMemorySessionStoreFactory::new(), + )) + .disable_queued_work_driver() + .build(lash::persistence::LeaseOwnerIdentity::opaque( + "lash-cli-test", + "queued-cancel", + )) + .expect("test core") + } + + #[tokio::test] + async fn queued_drain_reports_exact_turn_target_and_restores_settled_drop() { + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let started_tx = Arc::new(Mutex::new(Some(started_tx))); + let provider = lash::testing::TestProvider::builder() + .kind("queued-cancel-test") + .complete(move |_request| { + let started_tx = Arc::clone(&started_tx); + async move { + if let Some(tx) = started_tx.lock().expect("started lock").take() { + let _ = tx.send(()); + } + std::future::pending::<()>().await; + unreachable!("provider future is cancelled with the turn") + } + }) + .build() + .into_handle(); + let core = queued_cancel_test_core(provider); + let session = core + .session("queued-cancel-test") + .open() + .await + .expect("open test session"); + session + .enqueue(TurnInput::text("dropped queued input")) + .id("queued-cancel-input") + .send() + .await + .expect("enqueue test input"); + + let mut event_pump = AppEventPump::new(); + let (process_cancel, return_rx) = + spawn_session_queued_turn(session.clone(), 7, Some(event_pump.sender())); + tokio::time::timeout(Duration::from_secs(2), started_rx) + .await + .expect("queued drain reaches provider") + .expect("provider-start signal"); + let observed = tokio::time::timeout(Duration::from_secs(2), event_pump.recv()) + .await + .expect("turn target event") + .expect("event pump remains open"); + let AppEvent::ActiveTurnObserved { stream_id, turn_id } = observed.event else { + panic!("expected active-turn target event"); + }; + assert_eq!(stream_id, 7); + let dropped_steer = session + .enqueue(TurnInput::text("dropped queued steer")) + .id("queued-cancel-steer") + .ingress(lash::persistence::TurnInputIngress::active_turn( + &turn_id, + lash::persistence::TurnInputCheckpointBoundary::AfterWork, + )) + .send() + .await + .expect("enqueue active steer against observed queued turn"); + + let request = lash::TurnCancelRequest::new( + lash::TurnAddress::new(session.session_id(), &turn_id), + "queued-cancel-request", + Some("test-operator".to_string()), + ) + .undelivered(lash::TurnCancelDisposition::Drop); + let receipt = core + .turn_work_driver() + .request_cancel(request) + .await + .expect("submit exact durable cancel"); + assert!(matches!( + receipt.outcome, + lash::TurnCancelOutcome::Requested(_) + )); + process_cancel.cancel(); + + let done = tokio::time::timeout(Duration::from_secs(2), return_rx) + .await + .expect("queued drain settles") + .expect("queued drain returns report"); + assert!(matches!( + done.result.outcome, + lash::TurnOutcome::Stopped(lash::TurnStop::Cancelled { .. }) + )); + assert!( + done.result + .cancel_input_outcome + .affected_inputs + .iter() + .any(|input| input.input_id == dropped_steer.input_id + && matches!(input.disposition, lash::TurnCancelDisposition::Drop)) + ); + + let mut app = App::new( + "test-model".to_string(), + "test-session".to_string(), + session.session_id().to_string(), + ); + app.set_input("current draft".to_string()); + restore_cancelled_input_texts(&mut app, &done.result.cancel_input_outcome); + assert_eq!(app.input(), "dropped queued steer\n\ncurrent draft"); + } + + #[tokio::test] + async fn active_turn_target_sink_tracks_each_distinct_physical_turn_id() { + let mut event_pump = AppEventPump::new(); + let sink = ActiveTurnTargetSink::new(11, Some(event_pump.sender())); + sink.emit_for_turn( + "physical-turn-a", + lash::TurnActivity::independent(lash::TurnEvent::ModelRequestStarted { + protocol_iteration: 0, + }), + ) + .await; + sink.emit_for_turn( + "physical-turn-b", + lash::TurnActivity::independent(lash::TurnEvent::ModelRequestStarted { + protocol_iteration: 1, + }), + ) + .await; + + let first = event_pump.recv().await.expect("first target event"); + assert!(matches!( + first.event, + AppEvent::ActiveTurnObserved { stream_id: 11, ref turn_id } + if turn_id == "physical-turn-a" + )); + let second = event_pump.recv().await.expect("second target event"); + assert!(matches!( + second.event, + AppEvent::ActiveTurnObserved { stream_id: 11, ref turn_id } + if turn_id == "physical-turn-b" + )); + assert_eq!(event_pump.lane_depths(), (0, 0, 0)); + } +} diff --git a/crates/lash-cli/tests/cli_e2e.rs b/crates/lash-cli/tests/cli_e2e.rs index 3dd9d4b..34cf592 100644 --- a/crates/lash-cli/tests/cli_e2e.rs +++ b/crates/lash-cli/tests/cli_e2e.rs @@ -140,32 +140,52 @@ fn cli_interactive_pty_smoke_runs_turn_and_exits() { #[test] fn cli_interactive_pty_active_steer_escape_restores_editor_without_replay() { - let lash_home = test_lash_home("standard-slow-echo"); + let lash_home = test_lash_home("standard-gated-escape"); let mut harness = start_interactive_harness(&lash_home, ExecutionMode::Standard, None); harness - .send_line("slow initial prompt") - .expect("send slow prompt"); - std::thread::sleep(Duration::from_millis(250)); + .send_line("gated initial prompt") + .expect("send gated prompt"); + wait_for_marker( + lash_home.path(), + "gated-initial-started", + Duration::from_secs(10), + ); + harness + .wait_for_text("Thinking", Duration::from_secs(10)) + .expect("wait for injectable provider phase"); harness .type_text("queued after escape") .expect("type active steer"); harness.press_key("Enter").expect("submit active steer"); harness - .wait_for_text("queued after escape", Duration::from_secs(10)) + .wait_for_text("Will send in this turn", Duration::from_secs(10)) .expect("wait for active steer preview"); harness.press_key("Esc").expect("interrupt active turn"); harness - .wait_for_text("Manually interrupted.", Duration::from_secs(30)) - .expect("wait for manual interrupt marker"); + .wait_for_text("Manually interrupted.", Duration::from_secs(5)) + .expect("Escape returns to the redraw loop before provider release"); harness - .wait_for_text("queued after escape", Duration::from_secs(10)) - .expect("wait for restored editor draft"); - - let restored_screen = harness.screen_text().expect("read restored editor screen"); + .type_text("responsive draft") + .expect("type while cancellation is settling"); + let responsive = harness + .wait_for_text("responsive draft", Duration::from_secs(5)) + .expect("editor stays responsive before provider release"); + assert!( + !lash_home.path().join("gated-initial-release").exists() + && responsive.contains("responsive draft"), + "Escape must return control to the editor before the gated provider is released\nscreen:\n{responsive}" + ); + write_marker(lash_home.path(), "gated-initial-release"); + let restored_screen = wait_for_cancelled_draft_restore( + &mut harness, + "queued after escape", + Duration::from_secs(10), + ); assert!( restored_screen.contains("Manually interrupted.") && restored_screen.contains("queued after escape") + && restored_screen.contains("responsive draft") && !restored_screen.contains("test-provider echo: queued after escape") && !restored_screen.contains("Queued for next turn"), "screen should show the interrupted turn and restored, unsent steer\nscreen:\n{restored_screen}" @@ -620,3 +640,41 @@ fn run_lash_with_timeout(command: &mut Command, timeout: Duration) -> Output { std::thread::sleep(Duration::from_millis(50)); } } + +fn wait_for_marker(lash_home: &std::path::Path, name: &str, timeout: Duration) { + let path = lash_home.join(name); + let deadline = Instant::now() + timeout; + while !path.exists() { + assert!( + Instant::now() < deadline, + "timed out waiting for test-provider marker {}", + path.display() + ); + std::thread::sleep(Duration::from_millis(1)); + } +} + +fn write_marker(lash_home: &std::path::Path, name: &str) { + let path = lash_home.join(name); + std::fs::write(&path, b"release\n") + .unwrap_or_else(|err| panic!("write test-provider marker {}: {err}", path.display())); +} + +fn wait_for_cancelled_draft_restore( + harness: &mut LiveHarness, + restored_text: &str, + timeout: Duration, +) -> String { + let deadline = Instant::now() + timeout; + loop { + let screen = harness.screen_text().expect("read cancellation screen"); + if screen.contains(restored_text) && !screen.contains("Will send in this turn") { + return screen; + } + assert!( + Instant::now() < deadline, + "timed out waiting for settled cancellation report and restored draft\nscreen:\n{screen}" + ); + std::thread::sleep(Duration::from_millis(25)); + } +} From 122b646e4bdae4490aff019db954e88f5021e220 Mon Sep 17 00:00:00 2001 From: Samuel Galanakis Date: Tue, 25 Aug 2026 12:29:15 +0200 Subject: [PATCH 17/25] Align process-dock runbook with the durable process registry Subagent spawns now surface as transient dock rows backed by processes.db; the runbook premise still described the pre-upgrade dock that hid them. Release-Notes: none (runbook text only). --- runbooks/process-dock-lifecycle/runbook.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/runbooks/process-dock-lifecycle/runbook.md b/runbooks/process-dock-lifecycle/runbook.md index 68b0acc..779c8b4 100644 --- a/runbooks/process-dock-lifecycle/runbook.md +++ b/runbooks/process-dock-lifecycle/runbook.md @@ -6,8 +6,10 @@ **Purpose.** Prove the CLI process dock's contract: a **Runtime Process** shows in the dock (header `Background`), the focused process can be cancelled (`Delete`), and — the invariant -— **ending or deleting a session never ends a process by itself**. Only runtime processes -appear in the dock; a subagent spawn does not. +— **ending or deleting a session never ends a process by itself**. The dock uniformly +reflects the session's durable process registry (`session.processes().list()`): a subagent +spawn appears as a transient `subagent` row while it runs (plus a short post-completion +retention window), then is pruned. **Why this matters.** The Lash repository's CONTEXT.md → "Runtime Process" says its lifecycle is independent of any session and only runtime processes appear in the CLI @@ -23,10 +25,11 @@ by dry-run against every deterministic scenario: /fork /tree /version /info /model /variant /mode /provider /logout /retry /resume /skills /help /exit` (`crates/lash-cli/src/command.rs`). The dock is a render surface fed by `session.processes().list()`, not a command. -- The RLM `rlm-subagent-smoke` scenario **does** spawn a subagent, but it renders as an - **inline tool activity** — `◆ spawn subagent · …` with footer `Running tool · - spawn_agent` — and the `Background` dock header never appears. A subagent is not a dock - Runtime Process (consistent with the contract: "Only runtime processes appear"). +- The RLM `rlm-subagent-smoke` scenario **does** spawn a subagent. It renders **both** as + an inline tool activity — `◆ spawn subagent · …` with footer `Running tool · + spawn_agent` — and as a transient `Background` dock row (`◆ running · subagent · spawn`) + backed by a durable `processes.db` record, pruned shortly after completion. It is not a + long-lived Runtime Process: it cannot witness the deletion invariant. - The deterministic test provider only returns canned responses, so no scenario leaves a durable Runtime Process running; the dock stays empty (`app.processes.is_empty()` → `crates/lash-cli/src/render/sections/docks.rs` draws nothing). A **Process Engine** that From 1ac58f560d564ce3989295986d8795c8bbf256aa Mon Sep 17 00:00:00 2001 From: Samuel Galanakis Date: Tue, 25 Aug 2026 15:53:40 +0200 Subject: [PATCH 18/25] Make expand toggles work while scrolled and mark collapsed code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects made RLM code blocks effectively undiscoverable. `set_expand_level` applied only while the view followed output, so Alt+O and Ctrl+O were silent no-ops once the reader had scrolled up — exactly when they go looking for the code behind earlier activity. The gate is gone; in manual-scroll mode the viewport is re-anchored to the block that occupied its top row before re-layout (offset into that block preserved, then clamped to the recomputed max scroll), and follow mode is left untouched. Below full expansion a `LashlangCode` item rendered nothing at all, so nothing hinted that code existed or which key revealed it. Levels 0 and 1 now render a single dim marker row — `◇ · lines — Alt+O to expand`, using the session's RLM dialect when known and the neutral word `code` otherwise. Level 2 renders the full source as before. `/expand [compact|normal|full]` (numeric `0|1|2` accepted) sets the level directly and reports it with no argument, going through the same ungated path, so terminals that swallow Alt chords still have a way in. Release-Notes: Ctrl+O/Alt+O expansion toggles now work while scrolled up, hidden RLM code shows a dim `◇ · lines — Alt+O to expand` stub row, and the new `/expand compact|normal|full` command sets the expansion level directly. Closes #10 --- crates/lash-cli/src/app/view.rs | 121 ++++++++++++++++++ crates/lash-cli/src/command.rs | 28 +++- .../lash-cli/src/interactive/commands/mod.rs | 1 + .../src/interactive/commands/session.rs | 93 ++++++++++++++ crates/lash-cli/src/keybindings.rs | 4 +- crates/lash-cli/src/render/sections/blocks.rs | 25 +++- crates/lash-cli/src/render/tests.rs | 31 ++++- docs/index.html | 1 + 8 files changed, 295 insertions(+), 9 deletions(-) diff --git a/crates/lash-cli/src/app/view.rs b/crates/lash-cli/src/app/view.rs index 833eb4d..943964e 100644 --- a/crates/lash-cli/src/app/view.rs +++ b/crates/lash-cli/src/app/view.rs @@ -83,8 +83,49 @@ impl App { return; } + // Manual scroll: the toggle still applies, but every offset below + // the viewport moves when blocks change height, so re-anchor to the + // block the reader was looking at instead of keeping a raw line + // offset that now points somewhere else. + let width = self.render_cache.width; + let viewport_height = self.render_cache.viewport_height; + if width == 0 || viewport_height == 0 { + self.expand_level = level; + self.invalidate_height_cache(); + return; + } + + self.ensure_height_cache(width, viewport_height); + let anchor = self.viewport_top_anchor(); + self.expand_level = level; self.invalidate_height_cache(); + self.ensure_height_cache(width, viewport_height); + + let max_scroll = self + .total_content_height(width, viewport_height) + .saturating_sub(viewport_height); + let anchored = match anchor { + Some((idx, offset_into_block)) if idx < self.render_cache.heights.len() => { + let start = self.block_start_offset(idx); + let height = self.render_cache.heights[idx].saturating_sub(start); + start + offset_into_block.min(height) + } + _ => self.scroll_offset, + }; + self.scroll_offset = anchored.min(max_scroll); + } + + /// Index of the block occupying the top viewport row plus how far into + /// that block the row sits. `None` when the cache is empty or the + /// viewport starts past the end of history. + fn viewport_top_anchor(&self) -> Option<(usize, usize)> { + let heights = &self.render_cache.heights; + let idx = heights.partition_point(|end| *end <= self.scroll_offset); + if idx >= heights.len() { + return None; + } + Some((idx, self.scroll_offset - self.block_start_offset(idx))) } pub fn scroll_up(&mut self, amount: usize) { @@ -398,3 +439,83 @@ impl App { + crate::render::plan_dock_trailing_height(self) } } + +#[cfg(test)] +mod tests { + use super::*; + + const WIDTH: usize = 40; + const VIEWPORT: usize = 6; + + fn app_with_code_block() -> App { + let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); + let mut timeline = vec![ + UiTimelineItem::UserInput("first question".into()), + UiTimelineItem::LashlangCode("one\ntwo\nthree\nfour\nfive\nsix".into()), + ]; + // Enough trailing history that the anchored offset stays well + // below `max_scroll` — otherwise clamping, not anchoring, would + // decide the assertion. + timeline.extend( + (0..12).map(|idx| UiTimelineItem::UserInput(format!("follow-up question {idx}"))), + ); + app.timeline = timeline.into(); + app.expand_level = 0; + app.ensure_height_cache_pub(WIDTH, VIEWPORT); + app + } + + #[test] + fn expand_level_applies_while_scrolled_up() { + let mut app = app_with_code_block(); + // Park the viewport on the block right after the code block, in + // manual scroll mode — the state Alt+O used to silently ignore. + let block_two_start = app.height_cache_snapshot()[1]; + app.scroll_offset = block_two_start; + app.follow_mode = FollowOutputMode::Manual; + + app.set_expand_level(2); + + assert_eq!( + app.expand_level, 2, + "expand level must apply while scrolled" + ); + assert_eq!( + app.follow_mode, + FollowOutputMode::Manual, + "expanding must not silently resume follow mode", + ); + let expanded_block_two_start = app.height_cache_snapshot()[1]; + assert!( + expanded_block_two_start > block_two_start, + "code block should have grown at full expansion", + ); + assert_eq!( + app.scroll_offset, expanded_block_two_start, + "viewport should stay anchored to the same block after re-layout", + ); + } + + #[test] + fn collapsing_while_scrolled_clamps_to_max_scroll() { + let mut app = app_with_code_block(); + app.expand_level = 2; + app.invalidate_height_cache(); + app.ensure_height_cache_pub(WIDTH, VIEWPORT); + app.scroll_offset = app.total_content_height(WIDTH, VIEWPORT); + app.follow_mode = FollowOutputMode::Manual; + + app.set_expand_level(0); + + let max_scroll = app + .total_content_height(WIDTH, VIEWPORT) + .saturating_sub(VIEWPORT); + assert_eq!(app.expand_level, 0); + assert_eq!(app.follow_mode, FollowOutputMode::Manual); + assert!( + app.scroll_offset <= max_scroll, + "scroll offset {} exceeds max scroll {max_scroll} after collapsing", + app.scroll_offset, + ); + } +} diff --git a/crates/lash-cli/src/command.rs b/crates/lash-cli/src/command.rs index 35897b4..14b2620 100644 --- a/crates/lash-cli/src/command.rs +++ b/crates/lash-cli/src/command.rs @@ -118,6 +118,16 @@ pub const COMMANDS: &[CommandSpec] = &[ takes_argument: true, runs_out_of_band: false, }, + CommandSpec { + name: "/expand", + aliases: &[], + usage: "/expand [compact|normal|full]", + description: "Show or set timeline expansion (Ctrl+O cycles, Alt+O full)", + argument_hint: Some("[compact|normal|full]"), + argument_options: &["compact", "normal", "full"], + takes_argument: true, + runs_out_of_band: true, + }, CommandSpec { name: "/provider", aliases: &["/login"], @@ -293,6 +303,7 @@ pub fn runs_out_of_band_while_running(cmd: &Command) -> bool { Command::Model(_) => "/model", Command::Variant(_) => "/variant", Command::Mode(_) => "/mode", + Command::Expand(_) => "/expand", Command::ChangeProvider => "/provider", Command::Logout => "/logout", Command::Retry => "/retry", @@ -320,6 +331,7 @@ pub enum Command { Model(Option), Variant(Option), Mode(Option), + Expand(Option), ChangeProvider, Logout, Retry, @@ -376,6 +388,9 @@ pub fn parse(input: &str, _skills: &SkillCatalog) -> Option { "mode" => Some(Command::Mode( arg.filter(|a| !a.is_empty()).map(|a| a.to_string()), )), + "expand" => Some(Command::Expand( + arg.filter(|a| !a.is_empty()).map(|a| a.to_string()), + )), "provider" | "login" => Some(Command::ChangeProvider), "logout" => Some(Command::Logout), "retry" => Some(Command::Retry), @@ -486,6 +501,14 @@ mod tests { parse("/mode standard", &skills), Some(Command::Mode(Some(_))) )); + assert!(matches!( + parse("/expand full", &skills), + Some(Command::Expand(Some(ref arg))) if arg == "full" + )); + assert!(matches!( + parse("/expand", &skills), + Some(Command::Expand(None)) + )); assert!(matches!( parse("/resume", &skills), Some(Command::Resume(None)) @@ -502,7 +525,9 @@ mod tests { #[test] fn completion_spacing_matches_argument_commands() { let skills = SkillCatalog::from_dirs(&crate::paths::default_skill_dirs()); - for cmd in ["/compact", "/model", "/variant", "/mode", "/resume"] { + for cmd in [ + "/compact", "/model", "/variant", "/mode", "/expand", "/resume", + ] { assert!(completion_inserts_space(cmd, &skills)); } @@ -520,6 +545,7 @@ mod tests { Command::Info, Command::Skills, Command::Controls, + Command::Expand(Some("full".to_string())), Command::Exit, Command::Resume(None), ] { diff --git a/crates/lash-cli/src/interactive/commands/mod.rs b/crates/lash-cli/src/interactive/commands/mod.rs index 4a56ddf..464daf8 100644 --- a/crates/lash-cli/src/interactive/commands/mod.rs +++ b/crates/lash-cli/src/interactive/commands/mod.rs @@ -311,6 +311,7 @@ pub(super) async fn handle_builtin_command( command::Command::Mode(new_mode) => { provider::handle_mode(new_mode, app, current_execution_mode) } + command::Command::Expand(level) => session::handle_expand(level, app), command::Command::ChangeProvider => { provider::handle_change_provider( terminal, diff --git a/crates/lash-cli/src/interactive/commands/session.rs b/crates/lash-cli/src/interactive/commands/session.rs index 99d7391..ae63bd4 100644 --- a/crates/lash-cli/src/interactive/commands/session.rs +++ b/crates/lash-cli/src/interactive/commands/session.rs @@ -451,10 +451,103 @@ pub(super) fn handle_skills(app: &mut App) -> anyhow::Result { Ok(false) } +/// Human label for an expansion level, matching the `/expand` arguments. +fn expand_level_label(level: u8) -> &'static str { + match level { + 0 => "compact", + 1 => "normal", + _ => "full", + } +} + +const EXPAND_USAGE: &str = + "Usage: `/expand compact|normal|full` (Ctrl+O cycles compact/normal, Alt+O toggles full)"; + +/// Map a `/expand` argument to an expansion level. Numeric synonyms match +/// the internal levels so `/expand 2` and `/expand full` agree. +fn parse_expand_level(argument: &str) -> Result { + match argument.trim().to_ascii_lowercase().as_str() { + "compact" | "0" => Ok(0), + "normal" | "1" => Ok(1), + "full" | "2" => Ok(2), + other => Err(format!( + "Unknown expansion level `{other}`.\n{EXPAND_USAGE}" + )), + } +} + +pub(super) fn handle_expand(argument: Option, app: &mut App) -> anyhow::Result { + let Some(argument) = argument else { + push_system_message( + app, + format!( + "Current expansion: `{}`\n{EXPAND_USAGE}", + expand_level_label(app.expand_level) + ), + ); + return Ok(false); + }; + let level = match parse_expand_level(&argument) { + Ok(level) => level, + Err(message) => { + push_system_message(app, message); + return Ok(false); + } + }; + app.set_expand_level(level); + push_system_message( + app, + format!("Expansion set to `{}`.", expand_level_label(level)), + ); + Ok(false) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn expand_argument_accepts_names_and_numeric_synonyms() { + for (argument, level) in [ + ("compact", 0), + ("normal", 1), + ("full", 2), + ("0", 0), + ("1", 1), + ("2", 2), + (" FULL ", 2), + ] { + assert_eq!( + parse_expand_level(argument), + Ok(level), + "argument {argument}" + ); + } + } + + #[test] + fn expand_argument_rejects_junk_with_usage() { + let error = parse_expand_level("everything").expect_err("junk must be rejected"); + assert!( + error.contains("Unknown expansion level `everything`"), + "got {error}", + ); + assert!(error.contains("/expand compact|normal|full"), "got {error}"); + assert!(error.contains("Alt+O"), "got {error}"); + assert!(parse_expand_level("3").is_err()); + } + + #[test] + fn expand_level_labels_match_arguments() { + for level in [0u8, 1, 2] { + assert_eq!( + parse_expand_level(expand_level_label(level)), + Ok(level), + "label round-trip for {level}", + ); + } + } + #[test] fn fork_fallback_message_uses_resume_id_command() { let forked = fork::ForkedSession { diff --git a/crates/lash-cli/src/keybindings.rs b/crates/lash-cli/src/keybindings.rs index 3ec065a..a5500b7 100644 --- a/crates/lash-cli/src/keybindings.rs +++ b/crates/lash-cli/src/keybindings.rs @@ -160,8 +160,8 @@ pub(crate) fn shortcut_help_rows( } lines.extend([ - ShortcutHelpRow::new("Ctrl+O", "Cycle tool expansion"), - ShortcutHelpRow::new("Alt+O", "Full expansion"), + ShortcutHelpRow::new("Ctrl+O", "Cycle tool expansion (or `/expand`)"), + ShortcutHelpRow::new("Alt+O", "Full expansion (or `/expand full`)"), ShortcutHelpRow::new(history_arrows, "Input history"), ]); diff --git a/crates/lash-cli/src/render/sections/blocks.rs b/crates/lash-cli/src/render/sections/blocks.rs index 8ac789f..5a8a544 100644 --- a/crates/lash-cli/src/render/sections/blocks.rs +++ b/crates/lash-cli/src/render/sections/blocks.rs @@ -178,11 +178,14 @@ fn render_block_into( render_section_panel_block(&panel.title, &panel.content, lines, viewport_width); } UiTimelineItem::LashlangCode(code) => { - // Only shown at full expansion (Alt+O). At lower levels the - // block contributes zero lines — its tool activities carry - // the visible story already. + // The source itself is only shown at full expansion (Alt+O) — + // the tool activities carry the visible story already. Below + // that it collapses to a single dim marker row so the reader + // can see the code exists and which key reveals it. if expand_level >= 2 { render_lashlang_code_block(code, lines, viewport_width); + } else { + render_collapsed_code_stub(code, app.rlm_dialect_label.as_deref(), lines); } } UiTimelineItem::Splash => { @@ -256,6 +259,22 @@ fn collapsed_error_line(line: &str, char_truncated: &mut bool) -> String { /// Render the captured `lashlang` source for an RLM turn, with a dim `╎` /// gutter to mark it as "what the model ran" (distinct from the `│` /// shell gutter and `┊` reasoning gutter). +/// One dim row standing in for a hidden code block: glyph column, the +/// session's RLM dialect (or the neutral `code` when the dialect isn't +/// known here), the line count, and the key that reveals the source. +fn render_collapsed_code_stub(code: &str, dialect: Option<&str>, lines: &mut Vec>) { + let dialect = dialect.unwrap_or("code"); + let line_count = code.lines().count(); + let unit = if line_count == 1 { "line" } else { "lines" }; + lines.push(Line::from(vec![ + Span::styled("◇ ", theme::text_faint_style()), + Span::styled( + format!("{dialect} · {line_count} {unit} — Alt+O to expand"), + theme::text_faint_style(), + ), + ])); +} + fn render_lashlang_code_block(code: &str, lines: &mut Vec>, _viewport_width: usize) { let header_style = theme::code_chrome(); let gutter_style = theme::code_chrome(); diff --git a/crates/lash-cli/src/render/tests.rs b/crates/lash-cli/src/render/tests.rs index 431ede9..fe2c004 100644 --- a/crates/lash-cli/src/render/tests.rs +++ b/crates/lash-cli/src/render/tests.rs @@ -1105,19 +1105,44 @@ fn snippet_preview_wraps_long_markdown_bullets_to_viewport_width() { } #[test] -fn lashlang_code_block_is_hidden_below_full_expand() { +fn lashlang_code_block_collapses_to_a_stub_row_below_full_expand() { let blocks = vec![UiTimelineItem::LashlangCode( "r = await tools.read_file({ path: \"a\" })\nfinish r.value".to_string(), )]; for level in [0u8, 1] { let rendered = render_block(&blocks, 0, level, 80, 24); + let text: Vec = rendered.into_iter().map(line_to_plain_text).collect(); + assert_eq!( + text, + vec!["◇ code · 2 lines — Alt+O to expand".to_string()], + "expected a single stub row at expand_level {level}", + ); assert!( - rendered.is_empty(), - "expected no output at expand_level {level}, got {rendered:?}", + !text.iter().any(|line| line.contains("finish r.value")), + "code body must stay hidden at expand_level {level}; got {text:?}", ); } } +#[test] +fn lashlang_code_stub_row_names_the_session_dialect() { + let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); + app.timeline = vec![UiTimelineItem::LashlangCode("finish 1".to_string())].into(); + app.expand_level = 0; + app.set_rlm_dialect(Some(crate::execution_settings::RlmDialect::Typescript)); + + let text: Vec = render_block_lines(&app, 0, 80, 24) + .into_iter() + .map(line_to_plain_text) + .collect(); + + assert_eq!( + text, + vec!["◇ typescript · 1 line — Alt+O to expand".to_string()], + "stub row should name the session dialect", + ); +} + #[test] fn error_block_is_bounded_until_full_expand() { let message = (0..32) diff --git a/docs/index.html b/docs/index.html index c594012..bb7da40 100644 --- a/docs/index.html +++ b/docs/index.html @@ -60,6 +60,7 @@

    Slash Commands

    /model—/model [name]Show or switch the LLM model /variant—/variant [name]Show or switch the model variant (e.g. high, max) /mode—/modeShow the session's locked execution mode and, for RLM, its pinned dialect (for example rlm · typescript) + /expand—/expand [compact|normal|full]Show or set the timeline expansion level (0/1/2 also accepted); the same toggles as Ctrl+O and Alt+O, and it works while scrolled up /provider/login/providerSwitch, add, or re-authenticate providers /logout—/logoutRemove stored credentials for active provider /retry—/retryReplay the previous turn payload From 1fadbfa01345b2906f77787fffccf64476d556f1 Mon Sep 17 00:00:00 2001 From: Samuel Galanakis Date: Tue, 25 Aug 2026 15:56:53 +0200 Subject: [PATCH 19/25] Degrade gracefully after catalog sync failures Latch automatic post-turn refresh after an observed failure so later turns remain quiet while explicit refresh requests continue to report errors. Reset the latch whenever the interactive app installs or clears a session. Closes #9 Release-Notes: Report a failed automatic tool-catalog refresh once per session instead of after every turn. --- crates/lash-cli/src/app/catalog_sync.rs | 69 +++++++++++++++++++ crates/lash-cli/src/app/mod.rs | 4 ++ crates/lash-cli/src/app/view.rs | 1 + crates/lash-cli/src/config.rs | 15 ++-- .../src/interactive/commands/session.rs | 2 + crates/lash-cli/src/interactive/mod.rs | 15 ++-- crates/lash-cli/tests/cli_e2e.rs | 52 ++++++++++++++ crates/lash-debug-cli-harness/src/lib.rs | 6 ++ 8 files changed, 153 insertions(+), 11 deletions(-) create mode 100644 crates/lash-cli/src/app/catalog_sync.rs diff --git a/crates/lash-cli/src/app/catalog_sync.rs b/crates/lash-cli/src/app/catalog_sync.rs new file mode 100644 index 0000000..f1a8ce4 --- /dev/null +++ b/crates/lash-cli/src/app/catalog_sync.rs @@ -0,0 +1,69 @@ +use super::App; + +#[derive(Default)] +pub(super) struct AutomaticToolCatalogSyncState { + disabled: bool, +} + +impl AutomaticToolCatalogSyncState { + fn should_attempt(&self) -> bool { + !self.disabled + } + + fn observe_result(&mut self, result: &Result<(), E>) { + if result.is_err() { + self.disabled = true; + } + } + + fn reset(&mut self) { + self.disabled = false; + } +} + +impl App { + pub(crate) fn should_automatically_sync_tool_catalog(&self) -> bool { + self.automatic_tool_catalog_sync.should_attempt() + } + + pub(crate) fn observe_automatic_tool_catalog_sync_result(&mut self, result: &Result<(), E>) { + self.automatic_tool_catalog_sync.observe_result(result); + } + + pub(crate) fn reset_automatic_tool_catalog_sync(&mut self) { + self.automatic_tool_catalog_sync.reset(); + } +} + +#[cfg(test)] +mod tests { + use super::AutomaticToolCatalogSyncState; + + #[test] + fn failure_disables_automatic_sync() { + let mut state = AutomaticToolCatalogSyncState::default(); + + state.observe_result(&Err::<(), _>("sync failed")); + + assert!(!state.should_attempt()); + } + + #[test] + fn new_session_resets_automatic_sync() { + let mut state = AutomaticToolCatalogSyncState::default(); + state.observe_result(&Err::<(), _>("sync failed")); + + state.reset(); + + assert!(state.should_attempt()); + } + + #[test] + fn successful_sync_keeps_automatic_sync_enabled() { + let mut state = AutomaticToolCatalogSyncState::default(); + + state.observe_result(&Ok::<_, &str>(())); + + assert!(state.should_attempt()); + } +} diff --git a/crates/lash-cli/src/app/mod.rs b/crates/lash-cli/src/app/mod.rs index 47e7111..4e9b9fd 100644 --- a/crates/lash-cli/src/app/mod.rs +++ b/crates/lash-cli/src/app/mod.rs @@ -1,4 +1,5 @@ mod cache; +mod catalog_sync; mod input; mod live; mod projection; @@ -617,6 +618,8 @@ pub struct App { pub execution_mode_label: String, /// Pinned RLM dialect for the active session, when applicable. pub rlm_dialect_label: Option, + /// Failure latch for automatic post-turn catalog refreshes in this session. + automatic_tool_catalog_sync: catalog_sync::AutomaticToolCatalogSyncState, /// Unique session name (e.g. "alpine-canyon"). pub session_name: String, /// Live session id (UUID) for the active runtime. Updated on resume @@ -894,6 +897,7 @@ impl App { model_variant: Default::default(), execution_mode_label: "standard".to_string(), rlm_dialect_label: None, + automatic_tool_catalog_sync: Default::default(), session_name, session_id, repo_status: std::env::current_dir() diff --git a/crates/lash-cli/src/app/view.rs b/crates/lash-cli/src/app/view.rs index 943964e..58dfe02 100644 --- a/crates/lash-cli/src/app/view.rs +++ b/crates/lash-cli/src/app/view.rs @@ -2,6 +2,7 @@ use super::*; impl App { pub fn clear(&mut self) { + self.reset_automatic_tool_catalog_sync(); self.timeline = Vec::new().into(); self.scroll_offset = 0; self.follow_mode = FollowOutputMode::PinnedBottom; diff --git a/crates/lash-cli/src/config.rs b/crates/lash-cli/src/config.rs index abd7883..a39e564 100644 --- a/crates/lash-cli/src/config.rs +++ b/crates/lash-cli/src/config.rs @@ -566,14 +566,17 @@ fn rlm_typescript_smoke_provider() -> lash::testing::TestProvider { "scenario": "rlm-typescript-smoke", }) }) - .complete(|_request| async move { - let response = r#" -finish("typescript-ok"); -"#; + .complete(|request| async move { + let result = if request_contains_text(&request, "Second TypeScript turn") { + "typescript-ok-2" + } else { + "typescript-ok-1" + }; + let response = format!("\nfinish(\"{result}\");\n"); Ok(lash::provider::LlmResponse { - full_text: response.to_string(), + full_text: response.clone(), parts: vec![lash::direct::LlmOutputPart::Text { - text: response.to_string(), + text: response, response_meta: None, }], ..Default::default() diff --git a/crates/lash-cli/src/interactive/commands/session.rs b/crates/lash-cli/src/interactive/commands/session.rs index ae63bd4..880296e 100644 --- a/crates/lash-cli/src/interactive/commands/session.rs +++ b/crates/lash-cli/src/interactive/commands/session.rs @@ -34,6 +34,7 @@ async fn activate_opened_session( provider: &ProviderHandle, ) -> Result<(), String> { *runtime = Some(opened.session); + app.reset_automatic_tool_catalog_sync(); *logger = opened.logger; resume::load_resumed_session( opened.bootstrap.filename(), @@ -135,6 +136,7 @@ pub(super) async fn handle_clear( .fresh(policy, *current_execution_mode, new_rlm_dialect) .await?; *runtime = Some(opened.session); + app.reset_automatic_tool_catalog_sync(); *logger = opened.logger; let Some(session) = runtime.as_ref() else { return Err(anyhow::anyhow!("opened session was not installed")); diff --git a/crates/lash-cli/src/interactive/mod.rs b/crates/lash-cli/src/interactive/mod.rs index ee8b744..6a98dfc 100644 --- a/crates/lash-cli/src/interactive/mod.rs +++ b/crates/lash-cli/src/interactive/mod.rs @@ -364,11 +364,15 @@ pub(crate) async fn run_app( active_stream_id, "runtime return received in interactive loop" ); - if let Err(err) = sync_runtime_tool_catalog(&mut runtime).await { - push_system_message( - &mut app, - format!("Failed to sync tool catalog after turn: {err}"), - ); + if app.should_automatically_sync_tool_catalog() { + let result = sync_runtime_tool_catalog(&mut runtime).await; + app.observe_automatic_tool_catalog_sync_result(&result); + if result.is_err() { + push_system_message( + &mut app, + "Tool catalog could not be refreshed; automatic refresh is disabled for this session.", + ); + } } if done.stream_id != active_stream_id || pending_clear_after_return { if let Some(rt) = runtime.as_mut() { @@ -385,6 +389,7 @@ pub(crate) async fn run_app( app.usage.token_usage = TokenUsage::default(); app.stop_turn(); app.clear_mode_indicators(); + app.reset_automatic_tool_catalog_sync(); app.set_model_variant(current_model_variant.clone()); runtime_return_rx = None; cancel_token = None; diff --git a/crates/lash-cli/tests/cli_e2e.rs b/crates/lash-cli/tests/cli_e2e.rs index 34cf592..3eba447 100644 --- a/crates/lash-cli/tests/cli_e2e.rs +++ b/crates/lash-cli/tests/cli_e2e.rs @@ -432,6 +432,58 @@ fn cli_rlm_typescript_status_and_resume_conflict_are_pinned() { ); } +#[test] +fn cli_rlm_typescript_catalog_sync_failure_is_reported_at_most_once() { + let lash_home = test_lash_home("rlm-typescript-smoke"); + let mut harness = start_interactive_harness_with_dialect( + &lash_home, + ExecutionMode::Rlm, + Some("typescript"), + None, + ); + + for (prompt, response) in [ + ("First TypeScript turn", "■ typescript-ok-1"), + ("Second TypeScript turn", "■ typescript-ok-2"), + ] { + harness.send_line(prompt).expect("send TypeScript prompt"); + harness + .wait_for_text_while_idle(response, Duration::from_secs(45)) + .expect("wait for completed TypeScript turn"); + } + + let run = harness.finish_cleanly().expect("finish TypeScript harness"); + assert_eq!( + run.screen_text.matches("■ typescript-ok-").count(), + 2, + "both TypeScript turns must complete normally\nscreen:\n{}", + run.screen_text + ); + let trace: serde_json::Value = serde_json::from_slice( + &std::fs::read(&run.artifacts.ui_trace_json).expect("read TypeScript UI trace"), + ) + .expect("parse TypeScript UI trace"); + let catalog_sync_notices = trace["ops"] + .as_array() + .expect("UI trace ops") + .iter() + .filter(|op| { + op["op"] == "system_message" + && op["text"] + .as_str() + .is_some_and(|text| text.to_ascii_lowercase().contains("tool catalog")) + }) + .count(); + // lash-cli issue #9: tighten this assertion to zero once the Lash pin includes the + // upstream dialect-preservation fix. + assert!( + catalog_sync_notices <= 1, + "catalog-sync failure notice rendered {catalog_sync_notices} times\ntrace: {}\nscreen:\n{}", + run.artifacts.ui_trace_json.display(), + run.screen_text + ); +} + #[test] fn cli_interactive_pty_rlm_uses_temporary_working_directory() { let workspace = tempfile::tempdir().expect("temp workspace"); diff --git a/crates/lash-debug-cli-harness/src/lib.rs b/crates/lash-debug-cli-harness/src/lib.rs index bcfff93..d365991 100644 --- a/crates/lash-debug-cli-harness/src/lib.rs +++ b/crates/lash-debug-cli-harness/src/lib.rs @@ -228,6 +228,12 @@ impl LiveHarness { self.wait_until(needle, timeout, |visible| visible.contains(needle)) } + pub fn wait_for_text_while_idle(&mut self, needle: &str, timeout: Duration) -> Result { + self.wait_until(needle, timeout, |visible| { + visible.contains(needle) && visible_run_state_is_idle(visible) + }) + } + pub fn wait_idle(&mut self, timeout: Duration) -> Result { self.wait_settled_idle(timeout) } From d221e98d37a1b94069ec43e8b47e3d2274e6b7fb Mon Sep 17 00:00:00 2001 From: Samuel Galanakis Date: Tue, 25 Aug 2026 16:22:13 +0200 Subject: [PATCH 20/25] Reset the UI activity journal on session clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /clear left the previous session's UI activity journal in memory. Journal records are keyed by (turn_ordinal, lashlang_block_ordinal), and both ordinals restart in the new session, so the old session's activity rows (commands it ran, with their outputs) were spliced into the new session's timeline at matching positions — asserting executions that never happened in that session. The durable record and on-disk sidecars were unaffected; the leak was purely in-process rendering. Reset the journal, pending records, active turn ordinal, block ordinal, and block anchors in App::clear, with a regression test. Fixes #11. Release-Notes: /clear no longer leaks the previous session's activity rows into the new session's timeline. --- crates/lash-cli/src/app/view.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/lash-cli/src/app/view.rs b/crates/lash-cli/src/app/view.rs index 58dfe02..9811899 100644 --- a/crates/lash-cli/src/app/view.rs +++ b/crates/lash-cli/src/app/view.rs @@ -16,6 +16,11 @@ impl App { self.clear_pending_turn_input_snapshot(); self.overlay = None; self.activity_state.reset(); + self.ui_activity_journal = UiActivityJournal::default(); + self.pending_ui_activity_records.clear(); + self.active_ui_turn_ordinal = None; + self.next_lashlang_block_ordinal = 0; + self.lashlang_block_anchors.clear(); self.usage.token_usage = TokenUsage::default(); self.usage.last_response_usage = TokenUsage::default(); self.usage.last_prompt_usage = None; @@ -519,4 +524,26 @@ mod tests { app.scroll_offset, ); } + + #[test] + fn clear_drops_previous_session_activity_journal() { + let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); + let activity = ActivityBlock::new( + ActivityKind::ShellCommand, + "bash", + serde_json::json!({ "command": "nproc" }), + "nproc", + ActivityStatus::Completed, + serde_json::json!({ "output": "16\n" }), + 5, + ); + app.journal_lashlang_activity(Some((0, 0)), &activity); + assert!(!app.ui_activity_journal.is_empty()); + assert!(!app.pending_ui_activity_records().is_empty()); + + app.clear(); + + assert!(app.ui_activity_journal.is_empty()); + assert!(app.pending_ui_activity_records().is_empty()); + } } From 7ec1e23c3053b8d5746798c35258cf2dcb629a96 Mon Sep 17 00:00:00 2001 From: Samuel Galanakis Date: Tue, 25 Aug 2026 16:33:19 +0200 Subject: [PATCH 21/25] Make session-switch state resets exhaustive by construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App::clear() was a hand-maintained field list, so every new per-session field silently survived /clear unless someone remembered to add it — the exact mechanism behind the cross-session activity-row leak. Rewrite clear() around an exhaustive destructure of App with no rest pattern: every field must be explicitly classified as carried (process-scoped) or reset (session-scoped), and adding a field fails compilation until it is classified. Newly reset as a result: iteration, live stream chunks and timeline anchor, history top pad, queue suppression ids and pending option prompt responses, context window, execution mode and dialect labels, session identity, text selection, toast, and the mouse-up latch. Call clear() at session install time on both switch paths: handle_clear now clears before the new session's identity is applied (previously the setters ran first, which the exhaustive reset would have wiped), and activate_opened_session — which never cleared at all, leaking activity state, processes, plan dock, and overlays across /resume — clears before the resumed state is loaded. Release-Notes: session switches now reset all per-session UI state; resuming a session no longer carries over stray state from the previous one. --- crates/lash-cli/src/app/view.rs | 139 ++++++++++++++---- .../src/interactive/commands/session.rs | 5 +- 2 files changed, 112 insertions(+), 32 deletions(-) diff --git a/crates/lash-cli/src/app/view.rs b/crates/lash-cli/src/app/view.rs index 9811899..f1680f9 100644 --- a/crates/lash-cli/src/app/view.rs +++ b/crates/lash-cli/src/app/view.rs @@ -2,36 +2,115 @@ use super::*; impl App { pub fn clear(&mut self) { - self.reset_automatic_tool_catalog_sync(); - self.timeline = Vec::new().into(); - self.scroll_offset = 0; - self.follow_mode = FollowOutputMode::PinnedBottom; - self.live.assistant.clear(); - self.live.reasoning.clear(); self.clear_status(); - self.editor.pending_images.clear(); - self.editor.pending_large_pastes.clear(); - self.clear_live_tool_output(); - self.queues.draft_presentations.clear(); - self.clear_pending_turn_input_snapshot(); - self.overlay = None; - self.activity_state.reset(); - self.ui_activity_journal = UiActivityJournal::default(); - self.pending_ui_activity_records.clear(); - self.active_ui_turn_ordinal = None; - self.next_lashlang_block_ordinal = 0; - self.lashlang_block_anchors.clear(); - self.usage.token_usage = TokenUsage::default(); - self.usage.last_response_usage = TokenUsage::default(); - self.usage.last_prompt_usage = None; - self.usage.live_output_chars_estimate = 0; - self.usage.live_output_tokens_estimate = 0; - self.model_variant = None; - self.clear_mode_indicators(); - self.plan_dock = None; - self.processes.clear(); - self.selected_process_id = None; - self.invalidate_height_cache(); + + // Session-switch reset. EVERY App field must be classified below, exactly + // once: either reset (session-scoped) or bound as carried (process-scoped). + // Adding a field to App fails compilation here until you decide its fate. + // The editor carries typed input and history; pending images and large pastes + // are the process-scoped exception reset below. + let App { + // ---- carried across session switches (process/config-scoped) ---- + expand_level: _, + tick: _, + dirty, + render_cache, + editor, + skills: _, + focused: _, + model: _, + repo_status: _, + ui_extensions: _, + chrome_state: _, + cwd: _, + history_area: _, + file_index: _, + // ---- reset (session-scoped) ---- + timeline, + scroll_offset, + foreground_turn_active, + run_state, + iteration, + live, + follow_mode, + history_top_pad, + queues, + overlay, + usage, + model_variant, + execution_mode_label, + rlm_dialect_label, + automatic_tool_catalog_sync, + session_name, + session_id, + plugin_mode_indicators, + plan_dock, + processes, + selected_process_id, + activity_state, + ui_activity_journal, + pending_ui_activity_records, + active_ui_turn_ordinal, + next_lashlang_block_ordinal, + lashlang_block_anchors, + manual_interrupt_requested, + active_turn_id, + pending_retry_status, + selection, + toast, + suppress_mouse_up_after_selection_clear, + } = self; + + let mark_dirty = + !queues.pending_turn_input_snapshot.is_empty() || !plugin_mode_indicators.is_empty(); + + *timeline = UiTimeline::default(); + *scroll_offset = 0; + *foreground_turn_active = false; + *run_state = CliRunState::Idle; + *iteration = 0; + *live = LiveTurnView::default(); + *follow_mode = FollowOutputMode::PinnedBottom; + *history_top_pad = 0; + editor.pending_images.clear(); + editor.pending_large_pastes.clear(); + *queues = Queues::default(); + *overlay = None; + // context_window is model-scoped (set only at startup, /model, and + // resume); wiping it here would let the next session-switch policy + // fall back to a 1-token window. + *usage = UsageState { + context_window: usage.context_window, + ..UsageState::default() + }; + *model_variant = None; + *execution_mode_label = "standard".to_string(); + *rlm_dialect_label = None; + *automatic_tool_catalog_sync = Default::default(); + session_name.clear(); + session_id.clear(); + plugin_mode_indicators.clear(); + *plan_dock = None; + processes.clear(); + *selected_process_id = None; + activity_state.reset(); + *ui_activity_journal = UiActivityJournal::default(); + pending_ui_activity_records.clear(); + *active_ui_turn_ordinal = None; + *next_lashlang_block_ordinal = 0; + lashlang_block_anchors.clear(); + *manual_interrupt_requested = false; + *active_turn_id = None; + *pending_retry_status = None; + *selection = TextSelection::default(); + *toast = None; + *suppress_mouse_up_after_selection_clear = false; + + if mark_dirty { + *dirty = true; + } + render_cache.dirty_from = 0; + render_cache.blocks.clear(); } pub fn restore_prepared_turn(&mut self, turn: PreparedTurn) { @@ -538,6 +617,7 @@ mod tests { 5, ); app.journal_lashlang_activity(Some((0, 0)), &activity); + app.usage.context_window = Some(1_310_720); assert!(!app.ui_activity_journal.is_empty()); assert!(!app.pending_ui_activity_records().is_empty()); @@ -545,5 +625,6 @@ mod tests { assert!(app.ui_activity_journal.is_empty()); assert!(app.pending_ui_activity_records().is_empty()); + assert_eq!(app.usage.context_window, Some(1_310_720)); } } diff --git a/crates/lash-cli/src/interactive/commands/session.rs b/crates/lash-cli/src/interactive/commands/session.rs index 880296e..04a70f9 100644 --- a/crates/lash-cli/src/interactive/commands/session.rs +++ b/crates/lash-cli/src/interactive/commands/session.rs @@ -34,7 +34,7 @@ async fn activate_opened_session( provider: &ProviderHandle, ) -> Result<(), String> { *runtime = Some(opened.session); - app.reset_automatic_tool_catalog_sync(); + app.clear(); *logger = opened.logger; resume::load_resumed_session( opened.bootstrap.filename(), @@ -136,7 +136,7 @@ pub(super) async fn handle_clear( .fresh(policy, *current_execution_mode, new_rlm_dialect) .await?; *runtime = Some(opened.session); - app.reset_automatic_tool_catalog_sync(); + app.clear(); *logger = opened.logger; let Some(session) = runtime.as_ref() else { return Err(anyhow::anyhow!("opened session was not installed")); @@ -159,7 +159,6 @@ pub(super) async fn handle_clear( history.clear(); *turn_counter = 0; *last_turn = None; - app.clear(); app.set_model_variant(current_model_variant.clone()); app.set_execution_mode_label(current_execution_mode); app.timeline.push(UiTimelineItem::SystemMessage(format!( From fccd811f15280c3a72d459026cb5f401fc51f3ee Mon Sep 17 00:00:00 2001 From: Samuel Galanakis Date: Wed, 26 Aug 2026 21:32:44 +0200 Subject: [PATCH 22/25] Update Lash pin to main 8dac97fa5 and adopt landed fixes Re-pin all Lash git dependencies from 372a95fe9 to 8dac97fa5, naming the renamed lash-internal-* packages now that lash-runtime is the sole supported package (the facade alias and lash:: import paths are unchanged). Adopt the fixes we filed from the previous round: - FIG-2109: delete the hand-mirrored session schema constant; all three compatibility stamps (session schema 44, trace schema 10, remote protocol 47) now derive from public upstream constants. - FIG-2112: lash-export reads session relations from the read-only SessionSummary/SessionReadView surfaces; the mutable metadata open is deleted and the loaded types narrowed to a host-owned {session_id, relation} projection. - FIG-2114: the remaining lash_core imports move to their facade homes (SessionNodeRecordFacadeOps is now lash::persistence:: SessionNodeProjection); lash-cli drops its lash-core dependency entirely. - FIG-2138: the dialect-loss bug is fixed upstream; the catalog-sync failure e2e that depended on inducing it is replaced by a success-path e2e proving typescript sessions sync their tool catalog after turns. The sync-failure latch stays as generic degradation. Range adaptations: LlmResponse.full_text is a derived method (test provider literals updated), SessionRelation::ObserverIntent is removed from the export tree, and PluginHost::build_session lost its second parameter. The queued-turn path deliberately keeps drain_id and the ActiveTurnTargetSink: the new turn_id builder is mutually exclusive with drain idempotency and FIG-2110's start notification did not land. Release-Notes: update to Lash main 8dac97fa5; typescript RLM sessions no longer fail post-turn tool-catalog sync; session store schema 44, trace 10, remote protocol 47. --- Cargo.lock | 521 ++++++++++--------- Cargo.toml | 40 +- crates/lash-autoresearch/src/runtime.rs | 2 +- crates/lash-cli/Cargo.toml | 1 - crates/lash-cli/src/activity/mod.rs | 12 +- crates/lash-cli/src/autonomous.rs | 6 +- crates/lash-cli/src/config.rs | 7 - crates/lash-cli/src/info.rs | 28 +- crates/lash-cli/src/interactive/runtime.rs | 2 +- crates/lash-cli/src/mcp_discovery/catalog.rs | 2 +- crates/lash-cli/src/tree.rs | 2 +- crates/lash-cli/src/turn_runner.rs | 6 +- crates/lash-cli/tests/cli_e2e.rs | 13 +- crates/lash-export/src/html.rs | 25 +- crates/lash-export/src/html/entries.rs | 3 +- crates/lash-export/src/html/prompt.rs | 2 +- crates/lash-export/src/html/stats.rs | 3 +- crates/lash-export/src/json.rs | 4 +- crates/lash-export/src/lib.rs | 64 +-- crates/lash-export/src/trace.rs | 9 +- crates/lash-export/src/transcript.rs | 5 +- crates/lash-export/src/tree.rs | 22 +- crates/lash-search-tools/Cargo.toml | 2 +- crates/lash-search-tools/src/lib.rs | 6 +- crates/lash-search-tools/src/tests.rs | 2 +- docs/architecture.html | 2 +- docs/index.html | 2 +- 27 files changed, 393 insertions(+), 400 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8a63cf4..cd82559 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,7 +91,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -102,7 +102,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -890,7 +890,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -966,7 +966,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2057,7 +2057,7 @@ name = "lash-autoresearch" version = "0.0.0-dev" dependencies = [ "async-trait", - "lash-lashlang-runtime", + "lash-internal-lashlang-runtime", "lash-runtime", "lash-tui", "lash-tui-extensions", @@ -2086,31 +2086,30 @@ dependencies = [ "image", "lash-autoresearch", "lash-cli-perf", - "lash-core", "lash-debug-cli-harness", "lash-export", "lash-file-index", - "lash-lashlang-runtime", - "lash-llm-tools", - "lash-plugin-mcp", - "lash-plugin-process-controls", - "lash-protocol-rlm", - "lash-protocol-standard", - "lash-provider-anthropic", - "lash-provider-auth", - "lash-provider-google", - "lash-provider-openai", - "lash-rlm-types", + "lash-internal-lashlang", + "lash-internal-lashlang-runtime", + "lash-internal-llm-tools", + "lash-internal-plugin-mcp", + "lash-internal-plugin-process-controls", + "lash-internal-protocol-rlm", + "lash-internal-protocol-standard", + "lash-internal-provider-anthropic", + "lash-internal-provider-auth", + "lash-internal-provider-google", + "lash-internal-provider-openai", + "lash-internal-rlm-types", + "lash-internal-sqlite-store", + "lash-internal-standard-plugins", + "lash-internal-subagents", + "lash-internal-tool-support", + "lash-internal-trace", "lash-runtime", "lash-search-tools", - "lash-sqlite-store", - "lash-standard-plugins", - "lash-subagents", - "lash-tool-support", - "lash-trace", "lash-tui", "lash-tui-extensions", - "lashlang", "model2vec-rs", "pulldown-cmark", "rand 0.9.5", @@ -2140,33 +2139,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "lash-core" -version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=372a95fe9#372a95fe9bad48cfc40bb48b6c4be869dfe076ab" -dependencies = [ - "arc-swap", - "async-trait", - "chrono", - "futures-util", - "hmac", - "lash-http-transport", - "lash-sansio", - "lash-trace", - "proptest", - "rmp-serde", - "schemars", - "serde", - "serde_bytes", - "serde_json", - "sha2", - "thiserror 2.0.19", - "tokio", - "tokio-util", - "tracing", - "uuid", -] - [[package]] name = "lash-debug-cli-harness" version = "0.0.0-dev" @@ -2188,12 +2160,12 @@ version = "0.0.0-dev" dependencies = [ "anyhow", "clap", - "lash-core", - "lash-protocol-rlm", - "lash-rlm-types", + "lash-internal-core", + "lash-internal-protocol-rlm", + "lash-internal-rlm-types", + "lash-internal-sqlite-store", + "lash-internal-trace", "lash-runtime", - "lash-sqlite-store", - "lash-trace", "pulldown-cmark", "rusqlite", "serde", @@ -2213,31 +2185,82 @@ dependencies = [ ] [[package]] -name = "lash-http-transport" +name = "lash-internal-core" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=372a95fe9#372a95fe9bad48cfc40bb48b6c4be869dfe076ab" +source = "git+https://github.com/Ascending-AI/lash.git?rev=8dac97fa5#8dac97fa5dab0aeae14733ff3ddd860e53cabad1" +dependencies = [ + "arc-swap", + "async-trait", + "chrono", + "futures-util", + "hmac", + "lash-internal-http-transport", + "lash-internal-sansio", + "lash-internal-trace", + "proptest", + "rmp-serde", + "schemars", + "serde", + "serde_bytes", + "serde_json", + "sha2", + "thiserror 2.0.19", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "lash-internal-http-transport" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=8dac97fa5#8dac97fa5dab0aeae14733ff3ddd860e53cabad1" dependencies = [ "async-trait", "bytes", "httpdate", - "lash-sansio", + "lash-internal-sansio", "reqwest", "thiserror 2.0.19", "tokio", ] [[package]] -name = "lash-lashlang-runtime" +name = "lash-internal-lashlang" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=8dac97fa5#8dac97fa5dab0aeae14733ff3ddd860e53cabad1" +dependencies = [ + "async-trait", + "compact_str 0.9.1", + "futures-executor", + "lash-internal-core", + "lash-internal-sansio", + "lash-regress", + "num-bigint", + "num-traits", + "rmp-serde", + "rustc-hash 2.1.3", + "ryu-js", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.19", + "url", +] + +[[package]] +name = "lash-internal-lashlang-runtime" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=372a95fe9#372a95fe9bad48cfc40bb48b6c4be869dfe076ab" +source = "git+https://github.com/Ascending-AI/lash.git?rev=8dac97fa5#8dac97fa5dab0aeae14733ff3ddd860e53cabad1" dependencies = [ "async-trait", "chrono", - "lash-core", - "lash-remote-protocol", - "lash-sansio", - "lash-trace", - "lashlang", + "lash-internal-core", + "lash-internal-lashlang", + "lash-internal-remote-protocol", + "lash-internal-sansio", + "lash-internal-trace", "serde", "serde_json", "sha2", @@ -2249,37 +2272,37 @@ dependencies = [ ] [[package]] -name = "lash-llm-tools" +name = "lash-internal-llm-tools" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=372a95fe9#372a95fe9bad48cfc40bb48b6c4be869dfe076ab" +source = "git+https://github.com/Ascending-AI/lash.git?rev=8dac97fa5#8dac97fa5dab0aeae14733ff3ddd860e53cabad1" dependencies = [ "async-trait", "jsonschema", - "lash-core", - "lash-lashlang-runtime", - "lash-sansio", - "lash-tool-support", + "lash-internal-core", + "lash-internal-lashlang-runtime", + "lash-internal-sansio", + "lash-internal-tool-support", "serde_json", ] [[package]] -name = "lash-llm-transport" +name = "lash-internal-llm-transport" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=372a95fe9#372a95fe9bad48cfc40bb48b6c4be869dfe076ab" +source = "git+https://github.com/Ascending-AI/lash.git?rev=8dac97fa5#8dac97fa5dab0aeae14733ff3ddd860e53cabad1" dependencies = [ "async-trait", "bytes", - "lash-core", - "lash-http-transport", - "lash-sansio", + "lash-internal-core", + "lash-internal-http-transport", + "lash-internal-sansio", "serde_json", "tokio", ] [[package]] -name = "lash-plugin-mcp" +name = "lash-internal-plugin-mcp" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=372a95fe9#372a95fe9bad48cfc40bb48b6c4be869dfe076ab" +source = "git+https://github.com/Ascending-AI/lash.git?rev=8dac97fa5#8dac97fa5dab0aeae14733ff3ddd860e53cabad1" dependencies = [ "async-trait", "base64 0.22.1", @@ -2287,10 +2310,10 @@ dependencies = [ "futures-util", "http", "jsonschema", - "lash-core", - "lash-lashlang-runtime", - "lash-sansio", - "lash-tool-support", + "lash-internal-core", + "lash-internal-lashlang-runtime", + "lash-internal-sansio", + "lash-internal-tool-support", "rmcp", "serde", "serde_json", @@ -2301,42 +2324,42 @@ dependencies = [ ] [[package]] -name = "lash-plugin-process-controls" +name = "lash-internal-plugin-process-controls" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=372a95fe9#372a95fe9bad48cfc40bb48b6c4be869dfe076ab" +source = "git+https://github.com/Ascending-AI/lash.git?rev=8dac97fa5#8dac97fa5dab0aeae14733ff3ddd860e53cabad1" dependencies = [ "async-trait", - "lash-core", - "lash-lashlang-runtime", - "lash-tool-support", + "lash-internal-core", + "lash-internal-lashlang-runtime", + "lash-internal-tool-support", "serde_json", ] [[package]] -name = "lash-plugin-tool-output-budget" +name = "lash-internal-plugin-tool-output-budget" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=372a95fe9#372a95fe9bad48cfc40bb48b6c4be869dfe076ab" +source = "git+https://github.com/Ascending-AI/lash.git?rev=8dac97fa5#8dac97fa5dab0aeae14733ff3ddd860e53cabad1" dependencies = [ - "lash-core", - "lash-protocol-standard", + "lash-internal-core", + "lash-internal-protocol-standard", "serde", "serde_json", "sha2", ] [[package]] -name = "lash-protocol-rlm" +name = "lash-internal-protocol-rlm" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=372a95fe9#372a95fe9bad48cfc40bb48b6c4be869dfe076ab" +source = "git+https://github.com/Ascending-AI/lash.git?rev=8dac97fa5#8dac97fa5dab0aeae14733ff3ddd860e53cabad1" dependencies = [ "async-trait", "jsonschema", - "lash-core", - "lash-lashlang-runtime", - "lash-rlm-types", - "lash-sansio", - "lash-typescript", - "lashlang", + "lash-internal-core", + "lash-internal-lashlang", + "lash-internal-lashlang-runtime", + "lash-internal-rlm-types", + "lash-internal-sansio", + "lash-internal-typescript", "rmp-serde", "serde", "serde_bytes", @@ -2349,27 +2372,27 @@ dependencies = [ ] [[package]] -name = "lash-protocol-standard" +name = "lash-internal-protocol-standard" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=372a95fe9#372a95fe9bad48cfc40bb48b6c4be869dfe076ab" +source = "git+https://github.com/Ascending-AI/lash.git?rev=8dac97fa5#8dac97fa5dab0aeae14733ff3ddd860e53cabad1" dependencies = [ "async-trait", - "lash-core", - "lash-sansio", - "lash-tool-support", + "lash-internal-core", + "lash-internal-sansio", + "lash-internal-tool-support", "serde_json", ] [[package]] -name = "lash-provider-anthropic" +name = "lash-internal-provider-anthropic" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=372a95fe9#372a95fe9bad48cfc40bb48b6c4be869dfe076ab" +source = "git+https://github.com/Ascending-AI/lash.git?rev=8dac97fa5#8dac97fa5dab0aeae14733ff3ddd860e53cabad1" dependencies = [ "async-trait", "base64 0.22.1", - "lash-core", - "lash-llm-transport", - "lash-sansio", + "lash-internal-core", + "lash-internal-llm-transport", + "lash-internal-sansio", "reqwest", "serde", "serde_json", @@ -2377,15 +2400,15 @@ dependencies = [ ] [[package]] -name = "lash-provider-auth" +name = "lash-internal-provider-auth" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=372a95fe9#372a95fe9bad48cfc40bb48b6c4be869dfe076ab" +source = "git+https://github.com/Ascending-AI/lash.git?rev=8dac97fa5#8dac97fa5dab0aeae14733ff3ddd860e53cabad1" dependencies = [ "async-trait", "base64 0.22.1", "form_urlencoded", - "lash-core", - "lash-sansio", + "lash-internal-core", + "lash-internal-sansio", "reqwest", "serde_json", "sha2", @@ -2395,16 +2418,16 @@ dependencies = [ ] [[package]] -name = "lash-provider-google" +name = "lash-internal-provider-google" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=372a95fe9#372a95fe9bad48cfc40bb48b6c4be869dfe076ab" +source = "git+https://github.com/Ascending-AI/lash.git?rev=8dac97fa5#8dac97fa5dab0aeae14733ff3ddd860e53cabad1" dependencies = [ "async-trait", "base64 0.22.1", - "lash-core", - "lash-llm-transport", - "lash-provider-auth", - "lash-sansio", + "lash-internal-core", + "lash-internal-llm-transport", + "lash-internal-provider-auth", + "lash-internal-sansio", "reqwest", "serde", "serde_json", @@ -2414,21 +2437,22 @@ dependencies = [ ] [[package]] -name = "lash-provider-openai" +name = "lash-internal-provider-openai" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=372a95fe9#372a95fe9bad48cfc40bb48b6c4be869dfe076ab" +source = "git+https://github.com/Ascending-AI/lash.git?rev=8dac97fa5#8dac97fa5dab0aeae14733ff3ddd860e53cabad1" dependencies = [ "async-trait", "base64 0.22.1", "bytes", "futures-util", - "lash-core", - "lash-llm-transport", - "lash-provider-auth", - "lash-sansio", + "lash-internal-core", + "lash-internal-llm-transport", + "lash-internal-provider-auth", + "lash-internal-sansio", "reqwest", "serde", "serde_json", + "sha2", "tokio", "tokio-tungstenite", "tracing", @@ -2436,22 +2460,13 @@ dependencies = [ ] [[package]] -name = "lash-regress" -version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=372a95fe9#372a95fe9bad48cfc40bb48b6c4be869dfe076ab" -dependencies = [ - "hashbrown 0.16.1", - "memchr", -] - -[[package]] -name = "lash-remote-protocol" +name = "lash-internal-remote-protocol" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=372a95fe9#372a95fe9bad48cfc40bb48b6c4be869dfe076ab" +source = "git+https://github.com/Ascending-AI/lash.git?rev=8dac97fa5#8dac97fa5dab0aeae14733ff3ddd860e53cabad1" dependencies = [ "base64 0.22.1", - "lash-core", - "lash-sansio", + "lash-internal-core", + "lash-internal-sansio", "schemars", "serde", "serde_json", @@ -2459,46 +2474,19 @@ dependencies = [ ] [[package]] -name = "lash-rlm-types" -version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=372a95fe9#372a95fe9bad48cfc40bb48b6c4be869dfe076ab" -dependencies = [ - "lash-sansio", - "serde", - "serde_json", -] - -[[package]] -name = "lash-runtime" +name = "lash-internal-rlm-types" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=372a95fe9#372a95fe9bad48cfc40bb48b6c4be869dfe076ab" +source = "git+https://github.com/Ascending-AI/lash.git?rev=8dac97fa5#8dac97fa5dab0aeae14733ff3ddd860e53cabad1" dependencies = [ - "async-trait", - "futures-util", - "lash-core", - "lash-lashlang-runtime", - "lash-plugin-tool-output-budget", - "lash-protocol-rlm", - "lash-protocol-standard", - "lash-remote-protocol", - "lash-rlm-types", - "lash-sansio", - "lash-tool-support", - "lash-trace", - "lashlang", + "lash-internal-sansio", "serde", "serde_json", - "thiserror 2.0.19", - "tokio", - "tokio-util", - "tracing", - "uuid", ] [[package]] -name = "lash-sansio" +name = "lash-internal-sansio" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=372a95fe9#372a95fe9bad48cfc40bb48b6c4be869dfe076ab" +source = "git+https://github.com/Ascending-AI/lash.git?rev=8dac97fa5#8dac97fa5dab0aeae14733ff3ddd860e53cabad1" dependencies = [ "jsonschema", "schemars", @@ -2508,34 +2496,16 @@ dependencies = [ ] [[package]] -name = "lash-search-tools" -version = "0.0.0-dev" -dependencies = [ - "async-trait", - "fff-search", - "lash-core", - "lash-lashlang-runtime", - "lash-tool-support", - "regex", - "schemars", - "serde", - "serde_json", - "tempfile", - "tokio", - "tokio-util", -] - -[[package]] -name = "lash-sqlite-store" +name = "lash-internal-sqlite-store" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=372a95fe9#372a95fe9bad48cfc40bb48b6c4be869dfe076ab" +source = "git+https://github.com/Ascending-AI/lash.git?rev=8dac97fa5#8dac97fa5dab0aeae14733ff3ddd860e53cabad1" dependencies = [ "async-trait", "flate2", "futures-executor", - "lash-core", - "lash-sansio", - "lashlang", + "lash-internal-core", + "lash-internal-lashlang", + "lash-internal-sansio", "rmp-serde", "rusqlite", "serde", @@ -2550,59 +2520,59 @@ dependencies = [ ] [[package]] -name = "lash-standard-plugins" +name = "lash-internal-standard-plugins" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=372a95fe9#372a95fe9bad48cfc40bb48b6c4be869dfe076ab" +source = "git+https://github.com/Ascending-AI/lash.git?rev=8dac97fa5#8dac97fa5dab0aeae14733ff3ddd860e53cabad1" dependencies = [ "async-trait", - "lash-core", - "lash-plugin-process-controls", - "lash-plugin-tool-output-budget", - "lash-sansio", - "lash-tools", + "lash-internal-core", + "lash-internal-plugin-process-controls", + "lash-internal-plugin-tool-output-budget", + "lash-internal-sansio", + "lash-internal-tools", "serde", ] [[package]] -name = "lash-subagents" +name = "lash-internal-subagents" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=372a95fe9#372a95fe9bad48cfc40bb48b6c4be869dfe076ab" +source = "git+https://github.com/Ascending-AI/lash.git?rev=8dac97fa5#8dac97fa5dab0aeae14733ff3ddd860e53cabad1" dependencies = [ "async-trait", - "lash-core", - "lash-lashlang-runtime", - "lash-protocol-rlm", - "lash-rlm-types", - "lash-sansio", - "lash-tool-support", + "lash-internal-core", + "lash-internal-lashlang-runtime", + "lash-internal-protocol-rlm", + "lash-internal-rlm-types", + "lash-internal-sansio", + "lash-internal-tool-support", "serde", "serde_json", "tokio", ] [[package]] -name = "lash-tool-support" +name = "lash-internal-tool-support" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=372a95fe9#372a95fe9bad48cfc40bb48b6c4be869dfe076ab" +source = "git+https://github.com/Ascending-AI/lash.git?rev=8dac97fa5#8dac97fa5dab0aeae14733ff3ddd860e53cabad1" dependencies = [ "async-trait", - "lash-core", - "lash-lashlang-runtime", + "lash-internal-core", + "lash-internal-lashlang-runtime", "serde", "serde_json", "serde_path_to_error", ] [[package]] -name = "lash-tools" +name = "lash-internal-tools" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=372a95fe9#372a95fe9bad48cfc40bb48b6c4be869dfe076ab" +source = "git+https://github.com/Ascending-AI/lash.git?rev=8dac97fa5#8dac97fa5dab0aeae14733ff3ddd860e53cabad1" dependencies = [ "async-trait", - "lash-core", - "lash-lashlang-runtime", - "lash-sansio", - "lash-tool-support", + "lash-internal-core", + "lash-internal-lashlang-runtime", + "lash-internal-sansio", + "lash-internal-tool-support", "libc", "portable-pty", "reqwest", @@ -2614,12 +2584,12 @@ dependencies = [ ] [[package]] -name = "lash-trace" +name = "lash-internal-trace" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=372a95fe9#372a95fe9bad48cfc40bb48b6c4be869dfe076ab" +source = "git+https://github.com/Ascending-AI/lash.git?rev=8dac97fa5#8dac97fa5dab0aeae14733ff3ddd860e53cabad1" dependencies = [ "chrono", - "lash-sansio", + "lash-internal-sansio", "serde", "serde_json", "sha2", @@ -2628,59 +2598,90 @@ dependencies = [ ] [[package]] -name = "lash-tui" +name = "lash-internal-typescript" version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=8dac97fa5#8dac97fa5dab0aeae14733ff3ddd860e53cabad1" dependencies = [ - "anyhow", - "crossterm", - "unicode-width", + "lash-internal-lashlang", + "ryu-js", + "serde_json", + "swc_common", + "swc_ecma_ast", + "swc_ecma_parser", ] [[package]] -name = "lash-tui-extensions" +name = "lash-regress" version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=8dac97fa5#8dac97fa5dab0aeae14733ff3ddd860e53cabad1" +dependencies = [ + "hashbrown 0.16.1", + "memchr", +] + +[[package]] +name = "lash-runtime" +version = "0.0.0-dev" +source = "git+https://github.com/Ascending-AI/lash.git?rev=8dac97fa5#8dac97fa5dab0aeae14733ff3ddd860e53cabad1" dependencies = [ "async-trait", - "lash-runtime", - "lash-tui", + "futures-util", + "lash-internal-core", + "lash-internal-lashlang", + "lash-internal-lashlang-runtime", + "lash-internal-plugin-tool-output-budget", + "lash-internal-protocol-rlm", + "lash-internal-protocol-standard", + "lash-internal-remote-protocol", + "lash-internal-rlm-types", + "lash-internal-sansio", + "lash-internal-tool-support", + "lash-internal-trace", + "serde", "serde_json", + "thiserror 2.0.19", + "tokio", + "tokio-util", + "tracing", + "uuid", ] [[package]] -name = "lash-typescript" +name = "lash-search-tools" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=372a95fe9#372a95fe9bad48cfc40bb48b6c4be869dfe076ab" dependencies = [ - "lashlang", - "ryu-js", + "async-trait", + "fff-search", + "lash-internal-core", + "lash-internal-lashlang-runtime", + "lash-internal-tool-support", + "lash-runtime", + "regex", + "schemars", + "serde", "serde_json", - "swc_common", - "swc_ecma_ast", - "swc_ecma_parser", + "tempfile", + "tokio", + "tokio-util", +] + +[[package]] +name = "lash-tui" +version = "0.0.0-dev" +dependencies = [ + "anyhow", + "crossterm", + "unicode-width", ] [[package]] -name = "lashlang" +name = "lash-tui-extensions" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=372a95fe9#372a95fe9bad48cfc40bb48b6c4be869dfe076ab" dependencies = [ "async-trait", - "compact_str 0.9.1", - "futures-executor", - "lash-core", - "lash-regress", - "lash-sansio", - "num-bigint", - "num-traits", - "rmp-serde", - "rustc-hash 2.1.3", - "ryu-js", - "serde", + "lash-runtime", + "lash-tui", "serde_json", - "sha2", - "smallvec", - "thiserror 2.0.19", - "url", ] [[package]] @@ -3038,7 +3039,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3298,7 +3299,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -3633,7 +3634,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4005,7 +4006,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4064,7 +4065,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4476,7 +4477,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4724,7 +4725,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5606,7 +5607,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 15da7c8..793a28c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,27 +49,27 @@ uuid = "1" # The CLI tracks one reviewed Lash release. Dependency updates are explicit, # testable compatibility changes rather than an implicit lockstep workspace. -lash = { package = "lash-runtime", git = "https://github.com/Ascending-AI/lash.git", rev = "372a95fe9" } -lash-core = { git = "https://github.com/Ascending-AI/lash.git", rev = "372a95fe9" } -lash-lashlang-runtime = { git = "https://github.com/Ascending-AI/lash.git", rev = "372a95fe9" } -lash-llm-tools = { git = "https://github.com/Ascending-AI/lash.git", rev = "372a95fe9" } -lash-plugin-mcp = { git = "https://github.com/Ascending-AI/lash.git", rev = "372a95fe9" } +lash = { package = "lash-runtime", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" } +lash-core = { package = "lash-internal-core", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" } +lash-lashlang-runtime = { package = "lash-internal-lashlang-runtime", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" } +lash-llm-tools = { package = "lash-internal-llm-tools", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" } +lash-plugin-mcp = { package = "lash-internal-plugin-mcp", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" } # lash-plugin-plan-mode removed upstream -lash-plugin-process-controls = { git = "https://github.com/Ascending-AI/lash.git", rev = "372a95fe9" } -lash-protocol-rlm = { git = "https://github.com/Ascending-AI/lash.git", rev = "372a95fe9" } -lash-protocol-standard = { git = "https://github.com/Ascending-AI/lash.git", rev = "372a95fe9" } -lash-provider-anthropic = { git = "https://github.com/Ascending-AI/lash.git", rev = "372a95fe9" } -lash-provider-auth = { git = "https://github.com/Ascending-AI/lash.git", rev = "372a95fe9" } -lash-provider-google = { git = "https://github.com/Ascending-AI/lash.git", rev = "372a95fe9" } -lash-provider-openai = { git = "https://github.com/Ascending-AI/lash.git", rev = "372a95fe9" } -lash-rlm-types = { git = "https://github.com/Ascending-AI/lash.git", rev = "372a95fe9" } -lash-sqlite-store = { git = "https://github.com/Ascending-AI/lash.git", rev = "372a95fe9" } -lash-standard-plugins = { git = "https://github.com/Ascending-AI/lash.git", rev = "372a95fe9" } -lash-subagents = { git = "https://github.com/Ascending-AI/lash.git", rev = "372a95fe9" } -lash-tool-support = { git = "https://github.com/Ascending-AI/lash.git", rev = "372a95fe9" } -lash-trace = { git = "https://github.com/Ascending-AI/lash.git", rev = "372a95fe9" } -lash-typescript = { git = "https://github.com/Ascending-AI/lash.git", rev = "372a95fe9" } -lashlang = { git = "https://github.com/Ascending-AI/lash.git", rev = "372a95fe9" } +lash-plugin-process-controls = { package = "lash-internal-plugin-process-controls", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" } +lash-protocol-rlm = { package = "lash-internal-protocol-rlm", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" } +lash-protocol-standard = { package = "lash-internal-protocol-standard", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" } +lash-provider-anthropic = { package = "lash-internal-provider-anthropic", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" } +lash-provider-auth = { package = "lash-internal-provider-auth", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" } +lash-provider-google = { package = "lash-internal-provider-google", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" } +lash-provider-openai = { package = "lash-internal-provider-openai", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" } +lash-rlm-types = { package = "lash-internal-rlm-types", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" } +lash-sqlite-store = { package = "lash-internal-sqlite-store", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" } +lash-standard-plugins = { package = "lash-internal-standard-plugins", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" } +lash-subagents = { package = "lash-internal-subagents", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" } +lash-tool-support = { package = "lash-internal-tool-support", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" } +lash-trace = { package = "lash-internal-trace", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" } +lash-typescript = { package = "lash-internal-typescript", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" } +lashlang = { package = "lash-internal-lashlang", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" } lash-autoresearch = { path = "crates/lash-autoresearch" } lash-cli-perf = { path = "crates/lash-cli-perf" } diff --git a/crates/lash-autoresearch/src/runtime.rs b/crates/lash-autoresearch/src/runtime.rs index 439d1aa..a620183 100644 --- a/crates/lash-autoresearch/src/runtime.rs +++ b/crates/lash-autoresearch/src/runtime.rs @@ -574,7 +574,7 @@ mod tests { })]; factories.extend(lash::testing::test_code_protocol_factories()); let host = PluginHost::new(factories); - let session = host.build_session("root", None).expect("session"); + let session = host.build_session("root").expect("session"); let members = |session: &Arc| { session diff --git a/crates/lash-cli/Cargo.toml b/crates/lash-cli/Cargo.toml index fa030b1..c7ddab1 100644 --- a/crates/lash-cli/Cargo.toml +++ b/crates/lash-cli/Cargo.toml @@ -25,7 +25,6 @@ name = "lash" path = "src/main.rs" [dependencies] -lash-core = { workspace = true } lash = { workspace = true, features = ["rlm"] } lash-plugin-mcp = { workspace = true, features = ["lashlang"] } lash-plugin-process-controls = { workspace = true, features = ["lashlang"] } diff --git a/crates/lash-cli/src/activity/mod.rs b/crates/lash-cli/src/activity/mod.rs index ea8cf52..a4e3bf2 100644 --- a/crates/lash-cli/src/activity/mod.rs +++ b/crates/lash-cli/src/activity/mod.rs @@ -340,17 +340,17 @@ impl ActivityState { ) -> Vec { let name = activity_tool_name(name); let result = match &output.outcome { - lash_core::ToolCallOutcome::Failure(failure) => failure + lash::tools::ToolCallOutcome::Failure(failure) => failure .raw .as_ref() .map(lash::tools::ToolValue::to_json_value) .unwrap_or_else(|| output.value_for_projection()), - lash_core::ToolCallOutcome::Cancelled(cancellation) => cancellation + lash::tools::ToolCallOutcome::Cancelled(cancellation) => cancellation .raw .as_ref() .map(lash::tools::ToolValue::to_json_value) .unwrap_or_else(|| output.value_for_projection()), - lash_core::ToolCallOutcome::Success(_) => output.value_for_projection(), + lash::tools::ToolCallOutcome::Success(_) => output.value_for_projection(), }; let success = output.is_success(); if name == "batch" { @@ -360,9 +360,9 @@ impl ActivityState { return Vec::new(); } let status = match &output.outcome { - lash_core::ToolCallOutcome::Success(_) => ActivityStatus::Completed, - lash_core::ToolCallOutcome::Failure(_) => ActivityStatus::Failed, - lash_core::ToolCallOutcome::Cancelled(_) => ActivityStatus::Cancelled, + lash::tools::ToolCallOutcome::Success(_) => ActivityStatus::Completed, + lash::tools::ToolCallOutcome::Failure(_) => ActivityStatus::Failed, + lash::tools::ToolCallOutcome::Cancelled(_) => ActivityStatus::Cancelled, }; let mut ctx = ProjectCtx { name, diff --git a/crates/lash-cli/src/autonomous.rs b/crates/lash-cli/src/autonomous.rs index 5fc8e4c..056d4e0 100644 --- a/crates/lash-cli/src/autonomous.rs +++ b/crates/lash-cli/src/autonomous.rs @@ -91,9 +91,9 @@ impl AutonomousRenderer { .. } => { let status = match output.outcome { - lash_core::ToolCallOutcome::Success(_) => ToolStatus::Success, - lash_core::ToolCallOutcome::Failure(_) => ToolStatus::Failure, - lash_core::ToolCallOutcome::Cancelled(_) => ToolStatus::Cancelled, + lash::tools::ToolCallOutcome::Success(_) => ToolStatus::Success, + lash::tools::ToolCallOutcome::Failure(_) => ToolStatus::Failure, + lash::tools::ToolCallOutcome::Cancelled(_) => ToolStatus::Cancelled, }; eprintln!("{}", format_tool_line(&name, status, duration_ms)); } diff --git a/crates/lash-cli/src/config.rs b/crates/lash-cli/src/config.rs index a39e564..e36ca58 100644 --- a/crates/lash-cli/src/config.rs +++ b/crates/lash-cli/src/config.rs @@ -574,7 +574,6 @@ fn rlm_typescript_smoke_provider() -> lash::testing::TestProvider { }; let response = format!("\nfinish(\"{result}\");\n"); Ok(lash::provider::LlmResponse { - full_text: response.clone(), parts: vec![lash::direct::LlmOutputPart::Text { text: response, response_meta: None, @@ -603,7 +602,6 @@ fn standard_echo_provider() -> lash::testing::TestProvider { }; let response = format!("test-provider echo: {prompt}"); Ok(lash::provider::LlmResponse { - full_text: response.clone(), parts: vec![lash::direct::LlmOutputPart::Text { text: response, response_meta: None, @@ -634,7 +632,6 @@ fn standard_slow_echo_provider() -> lash::testing::TestProvider { "test-provider echo: interactive prompt" }; Ok(lash::provider::LlmResponse { - full_text: response.to_string(), parts: vec![lash::direct::LlmOutputPart::Text { text: response.to_string(), response_meta: None, @@ -666,7 +663,6 @@ fn standard_gated_escape_provider() -> lash::testing::TestProvider { "test-provider echo: interactive prompt" }; Ok(lash::provider::LlmResponse { - full_text: response.to_string(), parts: vec![lash::direct::LlmOutputPart::Text { text: response.to_string(), response_meta: None, @@ -702,7 +698,6 @@ finish result.value "# }; Ok(lash::provider::LlmResponse { - full_text: response.to_string(), parts: vec![lash::direct::LlmOutputPart::Text { text: response.to_string(), response_meta: None, @@ -733,7 +728,6 @@ if write.exit_code == 0 { } "#; Ok(lash::provider::LlmResponse { - full_text: response.to_string(), parts: vec![lash::direct::LlmOutputPart::Text { text: response.to_string(), response_meta: None, @@ -759,7 +753,6 @@ result = await shell.exec({ cmd: "sh -c 'echo qc-nonzero-stderr >&2; exit 7'" }) finish format("nonzero-smoke-ok exit={}", result.exit_code) "#; Ok(lash::provider::LlmResponse { - full_text: response.to_string(), parts: vec![lash::direct::LlmOutputPart::Text { text: response.to_string(), response_meta: None, diff --git a/crates/lash-cli/src/info.rs b/crates/lash-cli/src/info.rs index 7d78e1e..3d29170 100644 --- a/crates/lash-cli/src/info.rs +++ b/crates/lash-cli/src/info.rs @@ -12,9 +12,6 @@ use crate::keybindings::shortcut_help_rows; use crate::model_selection::provider_display_label; use crate::overlay::{DocumentRow, DocumentSection, DocumentState}; -// Mirrors the private `lash_sqlite_store::schema::SCHEMA_VERSION` at the pinned Lash revision. -const SESSION_SCHEMA: u32 = 41; - pub(crate) fn version_text() -> String { format!( "lash-cli {} @@ -26,7 +23,10 @@ durable formats: {}", fn durable_formats_text() -> String { let mut formats = vec![ - format!("session schema={SESSION_SCHEMA}"), + format!( + "session schema={}", + lash_sqlite_store::SESSION_SCHEMA_VERSION + ), format!("trace schema={}", lash_trace::TRACE_SCHEMA_VERSION), format!("remote protocol={}", lash::remote::REMOTE_PROTOCOL_VERSION), ]; @@ -313,13 +313,19 @@ mod tests { #[test] fn durable_format_versions_match_the_adopted_lash_pin() { - assert_eq!(lash_trace::TRACE_SCHEMA_VERSION, 9); - assert_eq!(lash::remote::REMOTE_PROTOCOL_VERSION, 46); - let expected_session_schema = format!("session schema={SESSION_SCHEMA}"); - assert!( - durable_formats_text() - .starts_with(&format!("{expected_session_schema}, trace schema=")) - ); + let displayed = durable_formats_text(); + assert!(displayed.contains(&format!( + "session schema={}", + lash_sqlite_store::SESSION_SCHEMA_VERSION + ))); + assert!(displayed.contains(&format!( + "trace schema={}", + lash_trace::TRACE_SCHEMA_VERSION + ))); + assert!(displayed.contains(&format!( + "remote protocol={}", + lash::remote::REMOTE_PROTOCOL_VERSION + ))); } use crate::keybindings::ShortcutHelpRow; diff --git a/crates/lash-cli/src/interactive/runtime.rs b/crates/lash-cli/src/interactive/runtime.rs index e17bb26..3ba2f20 100644 --- a/crates/lash-cli/src/interactive/runtime.rs +++ b/crates/lash-cli/src/interactive/runtime.rs @@ -39,7 +39,7 @@ pub(crate) async fn make_injected_plugin_message(turn: &PreparedTurn) -> PluginM parts.push(Part::attachment_part( String::new(), String::new(), - Some(lash_core::session_model::message::PartAttachment { source }), + Some(lash::messages::PartAttachment { source }), )); } } diff --git a/crates/lash-cli/src/mcp_discovery/catalog.rs b/crates/lash-cli/src/mcp_discovery/catalog.rs index 9bd56b3..8ee4bd6 100644 --- a/crates/lash-cli/src/mcp_discovery/catalog.rs +++ b/crates/lash-cli/src/mcp_discovery/catalog.rs @@ -1,5 +1,5 @@ //! MCP catalog projection. -use lash_core::CompactToolContract; +use lash::tools::CompactToolContract; use serde_json::{Value, json}; use super::common::round_score; diff --git a/crates/lash-cli/src/tree.rs b/crates/lash-cli/src/tree.rs index 7b783f9..4959ab0 100644 --- a/crates/lash-cli/src/tree.rs +++ b/crates/lash-cli/src/tree.rs @@ -1,6 +1,6 @@ use lash::LashSession; use lash::messages::{Message, MessageRole}; -use lash_core::facade_support::SessionNodeRecordFacadeOps; +use lash::persistence::SessionNodeProjection; use std::collections::{HashMap, HashSet}; use crate::app::{App, timeline_from_read_view}; diff --git a/crates/lash-cli/src/turn_runner.rs b/crates/lash-cli/src/turn_runner.rs index ad71c9d..5535bce 100644 --- a/crates/lash-cli/src/turn_runner.rs +++ b/crates/lash-cli/src/turn_runner.rs @@ -3,10 +3,10 @@ use lash::LashSession; use lash::TurnExecutionMetrics; use lash::TurnInput; use lash::persistence::RuntimeSessionState; +use lash::runtime::OutputState; use lash::turn::AssistantOutput; use lash::turn::TurnIssue; use lash::usage::TokenUsage; -use lash_core::facade_support::OutputState; use tokio::sync::oneshot; use tokio::task::JoinHandle; @@ -250,13 +250,13 @@ mod tests { )) .provider(provider) .model( - lash_core::ModelSpec::builder("queued-cancel-test") + lash::ModelSpec::builder("queued-cancel-test") .context_window_tokens(16_384) .build() .expect("test model"), ) .store_factory(Arc::new( - lash_core::facade_support::InMemorySessionStoreFactory::new(), + lash::persistence::InMemorySessionStoreFactory::new(), )) .disable_queued_work_driver() .build(lash::persistence::LeaseOwnerIdentity::opaque( diff --git a/crates/lash-cli/tests/cli_e2e.rs b/crates/lash-cli/tests/cli_e2e.rs index 3eba447..3e46603 100644 --- a/crates/lash-cli/tests/cli_e2e.rs +++ b/crates/lash-cli/tests/cli_e2e.rs @@ -433,7 +433,7 @@ fn cli_rlm_typescript_status_and_resume_conflict_are_pinned() { } #[test] -fn cli_rlm_typescript_catalog_sync_failure_is_reported_at_most_once() { +fn cli_rlm_typescript_post_turn_catalog_sync_succeeds() { let lash_home = test_lash_home("rlm-typescript-smoke"); let mut harness = start_interactive_harness_with_dialect( &lash_home, @@ -471,14 +471,13 @@ fn cli_rlm_typescript_catalog_sync_failure_is_reported_at_most_once() { op["op"] == "system_message" && op["text"] .as_str() - .is_some_and(|text| text.to_ascii_lowercase().contains("tool catalog")) + .is_some_and(|text| text.contains("Tool catalog could not be refreshed")) }) .count(); - // lash-cli issue #9: tighten this assertion to zero once the Lash pin includes the - // upstream dialect-preservation fix. - assert!( - catalog_sync_notices <= 1, - "catalog-sync failure notice rendered {catalog_sync_notices} times\ntrace: {}\nscreen:\n{}", + assert_eq!( + catalog_sync_notices, + 0, + "post-turn catalog sync failed {catalog_sync_notices} times\ntrace: {}\nscreen:\n{}", run.artifacts.ui_trace_json.display(), run.screen_text ); diff --git a/crates/lash-export/src/html.rs b/crates/lash-export/src/html.rs index 2f9fa89..027e373 100644 --- a/crates/lash-export/src/html.rs +++ b/crates/lash-export/src/html.rs @@ -29,10 +29,11 @@ mod tests { use super::*; use crate::LoadedSession; use crate::trace::{LlmCallUsage, LlmPromptSnapshot, RequestMessage}; + use lash::messages::{Message, MessageRole, Part}; use lash::persistence::{ChronologicalEntry, ChronologicalPayload}; - use lash_core::session_model::{Part, shared_parts}; use lash_rlm_types::RlmTrajectoryEntry; use std::path::PathBuf; + use std::sync::Arc; fn prompt_snapshot(protocol_iteration: u64, text: &str) -> LlmPromptSnapshot { LlmPromptSnapshot { @@ -60,20 +61,20 @@ mod tests { } } - fn user_message(id: &str, text: &str) -> lash_core::session_model::Message { - lash_core::session_model::Message { + fn user_message(id: &str, text: &str) -> Message { + Message { id: id.to_string(), - role: lash_core::session_model::MessageRole::User, - parts: shared_parts(vec![Part::text(format!("{id}.p0"), text.to_string(), None)]), + role: MessageRole::User, + parts: Arc::new(vec![Part::text(format!("{id}.p0"), text.to_string(), None)]), origin: None, } } - fn assistant_message(id: &str, text: &str) -> lash_core::session_model::Message { - lash_core::session_model::Message { + fn assistant_message(id: &str, text: &str) -> Message { + Message { id: id.to_string(), - role: lash_core::session_model::MessageRole::Assistant, - parts: shared_parts(vec![Part::text(format!("{id}.p0"), text.to_string(), None)]), + role: MessageRole::Assistant, + parts: Arc::new(vec![Part::text(format!("{id}.p0"), text.to_string(), None)]), origin: None, } } @@ -181,10 +182,10 @@ mod tests { "lookup".to_string(), None, ); - let assistant_msg = lash_core::session_model::Message { + let assistant_msg = Message { id: "m0".to_string(), - role: lash_core::session_model::MessageRole::Assistant, - parts: shared_parts(vec![tool_part]), + role: MessageRole::Assistant, + parts: Arc::new(vec![tool_part]), origin: None, }; let session = LoadedSession { diff --git a/crates/lash-export/src/html/entries.rs b/crates/lash-export/src/html/entries.rs index 77f3ccd..5e88651 100644 --- a/crates/lash-export/src/html/entries.rs +++ b/crates/lash-export/src/html/entries.rs @@ -1,8 +1,9 @@ use std::collections::HashMap; use std::fmt::Write as _; +use lash::messages::{Message, MessageRole, Part, PartKind}; use lash::persistence::ChronologicalPayload; -use lash_core::session_model::{Message, MessageRole, Part, PartKind, PruneState}; +use lash_core::session_model::PruneState; use super::escaping::{escape, escape_attr, json_highlight}; use super::prompt::{ diff --git a/crates/lash-export/src/html/prompt.rs b/crates/lash-export/src/html/prompt.rs index 24c39f6..587b26f 100644 --- a/crates/lash-export/src/html/prompt.rs +++ b/crates/lash-export/src/html/prompt.rs @@ -1,8 +1,8 @@ use std::collections::{HashMap, VecDeque}; use std::fmt::Write as _; +use lash::messages::MessageRole; use lash::persistence::{ChronologicalEntry, ChronologicalPayload}; -use lash_core::session_model::MessageRole; use crate::trace::LlmPromptSnapshot; use crate::transcript::{format_count, format_tokens, lashlang_transcript_step_from_event}; diff --git a/crates/lash-export/src/html/stats.rs b/crates/lash-export/src/html/stats.rs index 48a20cd..1ca4fcf 100644 --- a/crates/lash-export/src/html/stats.rs +++ b/crates/lash-export/src/html/stats.rs @@ -1,5 +1,6 @@ +use lash::messages::MessageRole; use lash::persistence::ChronologicalPayload; -use lash_core::session_model::{MessageRole, PruneState}; +use lash_core::session_model::PruneState; use crate::LoadedSession; use crate::transcript::lashlang_transcript_step_from_event; diff --git a/crates/lash-export/src/json.rs b/crates/lash-export/src/json.rs index e1ae72a..347483f 100644 --- a/crates/lash-export/src/json.rs +++ b/crates/lash-export/src/json.rs @@ -28,8 +28,8 @@ pub fn render(session: &LoadedSession) -> String { #[cfg(test)] mod tests { use super::*; + use lash::messages::{Message, MessageRole, Part}; use lash::persistence::{ChronologicalEntry, ChronologicalPayload}; - use lash_core::{Message, MessageRole}; use std::path::PathBuf; #[test] @@ -41,7 +41,7 @@ mod tests { payload: ChronologicalPayload::Message(Message { id: "m1".to_string(), role: MessageRole::User, - parts: std::sync::Arc::new(vec![lash_core::Part::text( + parts: std::sync::Arc::new(vec![Part::text( "p1".to_string(), "hello".to_string(), None, diff --git a/crates/lash-export/src/lib.rs b/crates/lash-export/src/lib.rs index 94daa02..c504bd1 100644 --- a/crates/lash-export/src/lib.rs +++ b/crates/lash-export/src/lib.rs @@ -10,12 +10,8 @@ use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow}; -use lash::persistence::{ - ChronologicalEntry, SessionMeta, SessionReadView, SessionRelation, SessionStoreCreateRequest, - SessionStoreFactory, -}; +use lash::persistence::{ChronologicalEntry, SessionReadView, SessionRelation}; use lash::preflight::{PreflightOptions, PreflightOutcome, probe_store}; -use lash::runtime::SessionPolicy; use lash_sqlite_store::{SqliteSessionStoreFactory, SqliteStorePreflight}; pub mod html; @@ -50,7 +46,7 @@ impl ExportFormat { /// A loaded session ready to be rendered. pub struct LoadedSession { - pub meta: Option, + pub meta: Option, pub chronological: Vec, pub trace_path: PathBuf, /// Derived from the current persisted session-head policy. @@ -61,6 +57,18 @@ pub struct LoadedSession { pub llm_prompts: Vec, } +#[derive(Clone, Debug)] +pub struct LoadedSessionMetadata { + pub session_id: String, + pub relation: SessionRelation, +} + +impl LoadedSessionMetadata { + pub fn parent_session_id(&self) -> Option<&str> { + self.relation.parent_session_id() + } +} + /// One SQLite schema stamp that refuses the current Lash build. #[derive(Clone, Debug, PartialEq, Eq)] pub struct SchemaStampMismatch { @@ -82,7 +90,6 @@ pub enum LoadSessionError { message: String, }, Preflight(lash::persistence::StoreError), - OpenStore(String), SessionNotFound(String), Store(lash::persistence::StoreError), Trace(anyhow::Error), @@ -94,7 +101,6 @@ impl fmt::Display for LoadSessionError { Self::IncompatibleStore { message, .. } => f.write_str(message), Self::PreflightUndecided { message, .. } => f.write_str(message), Self::Preflight(error) => write!(f, "preflighting session store: {error}"), - Self::OpenStore(error) => write!(f, "opening existing session store: {error}"), Self::SessionNotFound(session_id) => { write!(f, "session `{session_id}` was not found in the store") } @@ -111,7 +117,6 @@ impl Error for LoadSessionError { Self::Trace(error) => Some(error.as_ref()), Self::IncompatibleStore { .. } | Self::PreflightUndecided { .. } - | Self::OpenStore(_) | Self::SessionNotFound(_) => None, } } @@ -125,7 +130,14 @@ pub async fn load_session_from_paths( ) -> std::result::Result { preflight_store(store_root).await?; let read_view = open_session_read_only(store_root, session_id).await?; - let mut loaded = load_session(&read_view, session_id, None)?; + let meta = read_view + .durable_relation() + .cloned() + .map(|relation| LoadedSessionMetadata { + session_id: session_id.to_string(), + relation, + }); + let mut loaded = load_session(&read_view, session_id, meta)?; loaded.trace_path = trace_path.to_path_buf(); loaded.llm_prompts = trace::load_prompts_from_trace(trace_path).map_err(LoadSessionError::Trace)?; @@ -190,34 +202,10 @@ pub(crate) async fn open_session_read_only( .ok_or_else(|| LoadSessionError::SessionNotFound(session_id.to_string())) } -/// Open the mutable persistence seam only for relation metadata that the -/// read-only `SessionReadView` and catalog summary do not expose. -pub(crate) async fn open_session_metadata( - store_root: &Path, - session_id: &str, -) -> std::result::Result { - let factory = SqliteSessionStoreFactory::new(store_root); - let request = SessionStoreCreateRequest { - session_id: session_id.to_string(), - relation: SessionRelation::Root, - policy: SessionPolicy::new(lash::TurnBudget::Unbounded), - }; - let store = factory - .open_existing_store(&request) - .await - .map_err(LoadSessionError::OpenStore)? - .ok_or_else(|| LoadSessionError::SessionNotFound(session_id.to_string()))?; - store - .load_session_meta() - .await - .map_err(LoadSessionError::Store)? - .ok_or_else(|| LoadSessionError::SessionNotFound(session_id.to_string())) -} - pub(crate) fn load_session( read_view: &SessionReadView, session_id: &str, - meta: Option, + meta: Option, ) -> std::result::Result { if read_view.session_id() != session_id { return Err(LoadSessionError::SessionNotFound(session_id.to_string())); @@ -315,7 +303,6 @@ mod tests { .complete(|_request| async move { let text = "runtime fixture answer".to_string(); Ok(LlmResponse { - full_text: text.clone(), parts: vec![LlmOutputPart::Text { text, response_meta: None, @@ -454,9 +441,10 @@ mod tests { assert_eq!(stamps.len(), 1); assert_eq!(stamps[0].database, "durable core"); assert_eq!(stamps[0].found, 11); - assert_eq!(stamps[0].expected, 41); + let expected_schema = i64::from(lash_sqlite_store::SESSION_SCHEMA_VERSION); + assert_eq!(stamps[0].expected, expected_schema); assert!(message.contains("11")); - assert!(message.contains("41")); + assert!(message.contains(&expected_schema.to_string())); } other => panic!("expected typed schema refusal, got {other}"), } diff --git a/crates/lash-export/src/trace.rs b/crates/lash-export/src/trace.rs index 075e0da..1f4f8da 100644 --- a/crates/lash-export/src/trace.rs +++ b/crates/lash-export/src/trace.rs @@ -35,7 +35,7 @@ pub struct LlmPromptSnapshot { pub protocol_iteration: Option, pub llm_call_id: Option, /// Typed causal source from `context.metadata.caused_by`. - pub caused_by: Option, + pub caused_by: Option, pub timestamp: Option, pub model: Option, pub model_variant: Option, @@ -275,10 +275,6 @@ fn short_hash(text: &str) -> String { mod tests { use super::*; - #[test] - fn consumes_current_trace_schema() { - assert_eq!(lash_trace::TRACE_SCHEMA_VERSION, 9); - } use lash_trace::{TraceContext, TraceLlmMessage, TraceRecord}; use std::io::Write; @@ -429,7 +425,8 @@ mod tests { writeln!(tmp, "not json").unwrap(); writeln!( tmp, - r#"{{"schema_version":9,"id":"x","timestamp":"t","context":{{}},"type":"future_event","payload":{{}}}}"# + r#"{{"schema_version":{},"id":"x","timestamp":"t","context":{{}},"type":"future_event","payload":{{}}}}"#, + lash_trace::TRACE_SCHEMA_VERSION ) .unwrap(); let started = TraceRecord::new( diff --git a/crates/lash-export/src/transcript.rs b/crates/lash-export/src/transcript.rs index f6c9dc7..97bcaea 100644 --- a/crates/lash-export/src/transcript.rs +++ b/crates/lash-export/src/transcript.rs @@ -1,7 +1,8 @@ use std::collections::HashSet; +use lash::messages::{Message, MessageRole, PartKind}; +use lash::persistence::ProtocolEvent; use lash::persistence::{ChronologicalEntry, ChronologicalPayload, ChronologicalProjection}; -use lash_core::session_model::{Message, MessageRole, PartKind, ProtocolEvent}; #[derive(Clone, Debug, PartialEq)] pub struct LashlangTranscriptStep { @@ -230,7 +231,7 @@ pub fn format_tokens(n: i64) -> String { mod tests { use std::sync::Arc; - use lash_core::session_model::Part; + use lash::messages::Part; use super::*; diff --git a/crates/lash-export/src/tree.rs b/crates/lash-export/src/tree.rs index 476b7d1..27e5084 100644 --- a/crates/lash-export/src/tree.rs +++ b/crates/lash-export/src/tree.rs @@ -13,16 +13,16 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use lash::persistence::{ChronologicalEntry, SessionMeta, SessionRelation}; +use lash::persistence::{ChronologicalEntry, SessionRelation}; use crate::trace::{LlmPromptSnapshot, load_prompts_from_trace}; use crate::{ - LoadSessionError, load_session, open_session_metadata, open_session_read_only, preflight_store, + LoadSessionError, LoadedSessionMetadata, load_session, open_session_read_only, preflight_store, }; /// One session in the tree, ready to render. pub struct LoadedSessionNode { - pub meta: SessionMeta, + pub meta: LoadedSessionMetadata, pub chronological: Vec, pub model_id: Option, pub context_window_tokens: Option, @@ -65,7 +65,7 @@ pub struct LoadedSessionTree { struct CandidateLoad { db_path: PathBuf, - meta: SessionMeta, + meta: LoadedSessionMetadata, chronological: Vec, model_id: Option, context_window_tokens: Option, @@ -143,8 +143,14 @@ pub async fn load_tree_from_paths( .map_err(LoadSessionError::Store)?; for summary in summaries { let session_id = summary.session_id; + let relation = summary + .durable_relation + .ok_or_else(|| LoadSessionError::SessionNotFound(session_id.clone()))?; let read_view = open_session_read_only(store_root, &session_id).await?; - let meta = open_session_metadata(store_root, &session_id).await?; + let meta = LoadedSessionMetadata { + session_id: session_id.clone(), + relation, + }; let loaded = load_session(&read_view, &session_id, Some(meta.clone()))?; candidates.push(CandidateLoad { db_path: store_root.join("durable-core.db"), @@ -222,7 +228,7 @@ pub async fn load_tree_from_paths( }, ); } - SessionRelation::Fork { .. } | SessionRelation::ObserverIntent { .. } => {} + SessionRelation::Fork { .. } => {} } } @@ -302,9 +308,9 @@ pub async fn load_tree_from_paths( }) } -fn tool_call_id_from_cause(caused_by: &Option) -> Option { +fn tool_call_id_from_cause(caused_by: &Option) -> Option { match caused_by { - Some(lash_core::CausalRef::ToolCall { call_id, .. }) => Some(call_id.clone()), + Some(lash::process::CausalRef::ToolCall { call_id, .. }) => Some(call_id.clone()), _ => None, } } diff --git a/crates/lash-search-tools/Cargo.toml b/crates/lash-search-tools/Cargo.toml index 1b57053..34990cd 100644 --- a/crates/lash-search-tools/Cargo.toml +++ b/crates/lash-search-tools/Cargo.toml @@ -20,7 +20,7 @@ fff-zlob = ["fff-search/zlob"] lashlang = ["dep:lash-lashlang-runtime", "lash-tool-support/lashlang"] [dependencies] -lash-core = { workspace = true } +lash = { workspace = true } lash-lashlang-runtime = { workspace = true, optional = true } lash-tool-support = { workspace = true } async-trait = { workspace = true } diff --git a/crates/lash-search-tools/src/lib.rs b/crates/lash-search-tools/src/lib.rs index 547e99a..e4732f6 100644 --- a/crates/lash-search-tools/src/lib.rs +++ b/crates/lash-search-tools/src/lib.rs @@ -16,7 +16,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use lash_core::{ToolCall, ToolDefinition, ToolFailureClass, ToolOutcome, ToolRetryPolicy}; +use lash::tools::{ToolCall, ToolDefinition, ToolFailureClass, ToolOutcome, ToolRetryPolicy}; use lash_tool_support::{ StaticToolExecute, StaticToolProvider, ToolDefinitionBindingExt, execution_failure, @@ -1197,13 +1197,13 @@ fn timeout_grep_result(query: &str, stage: &str, budget: Duration, message: &str }, extra, )); - let mut failure = lash_core::ToolFailure::safe_retry( + let mut failure = lash::tools::ToolFailure::safe_retry( ToolFailureClass::Timeout, "grep_timeout", message, Some(50), ); - failure.raw = Some(lash_core::ToolValue::untrusted_json(raw)); + failure.raw = Some(lash::tools::ToolValue::untrusted_json(raw)); ToolOutcome::failure(failure) } diff --git a/crates/lash-search-tools/src/tests.rs b/crates/lash-search-tools/src/tests.rs index 80a0f91..4df99d8 100644 --- a/crates/lash-search-tools/src/tests.rs +++ b/crates/lash-search-tools/src/tests.rs @@ -1,7 +1,7 @@ #[cfg(test)] mod tests { use super::*; - use lash_core::ToolProvider; + use lash::tools::ToolProvider; use serde_json::json; use tempfile::TempDir; diff --git a/docs/architecture.html b/docs/architecture.html index c2474b2..1b3559d 100644 --- a/docs/architecture.html +++ b/docs/architecture.html @@ -64,7 +64,7 @@

    Ownership boundary

    ┌──────┴────────┐ ▼ ▼ interactive TUI --print / JSON / RPC -

    Execution modes such as standard and rlm are Lash protocol plugins. The CLI chooses and configures one for a session; it does not implement protocol semantics itself. Every session lives in one Lash catalog under ~/.lash/store/; listing, resume, startup, and tree discovery take durable identity from that catalog, while the CLI host roster supplies display metadata only. Catalog-only sessions remain addressable with fallback labels. Store preflight runs before provider or session startup, and shutdown flushes the trace sink before closing each core. This pin reads session schema 41, trace schema 9, and remote protocol 46.

    +

    Execution modes such as standard and rlm are Lash protocol plugins. The CLI chooses and configures one for a session; it does not implement protocol semantics itself. Every session lives in one Lash catalog under ~/.lash/store/; listing, resume, startup, and tree discovery take durable identity from that catalog, while the CLI host roster supplies display metadata only. Catalog-only sessions remain addressable with fallback labels. Store preflight runs before provider or session startup, and shutdown flushes the trace sink before closing each core. This pin reads session schema 44, trace schema 10, and remote protocol 47.

    Dependency rule

    diff --git a/docs/index.html b/docs/index.html index bb7da40..e40836a 100644 --- a/docs/index.html +++ b/docs/index.html @@ -311,7 +311,7 @@

    Environment Variables

    Resume & Fork

    Every session has a UUID and normally a generated human-readable name. --resume <id_or_name> accepts either; legacy .db filenames are refused as incompatible. Without an argument, /resume opens an interactive picker showing the 50 most recent live root sessions from Lash's unified catalog, sorted by recency. The host roster contributes display metadata only, so catalog-only sessions remain visible with fallback labels. The picker hides zero-turn sessions when any non-empty session exists; direct /resume <id-or-name> may still target any session.

    -

    The current durable compatibility markers are session schema 41, trace schema 9, and remote protocol 46. lash --version and /info report them.

    +

    The current durable compatibility markers are session schema 44, trace schema 10, and remote protocol 47. lash --version and /info report them.

    /fork branches the current session at the current message and opens the fork in a new terminal window. Lash resolves the launcher from the running executable and falls back to finding lash on $PATH if that binary isn't launchable. The launched fork runs lash --resume <new-session-id> in the new terminal.

    From 26524547e241cc61edb581017cc0583da313cdeb Mon Sep 17 00:00:00 2001 From: Samuel Galanakis Date: Wed, 26 Aug 2026 22:16:58 +0200 Subject: [PATCH 23/25] Own visible queued-turn execution in the interactive CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A message queued for the next turn could be claimed by the runtime's automatic broad queued-work drain when the post-turn tool-catalog sync ran: the turn executed headlessly with no UI sink, so neither the user row nor the response ever rendered, while both committed durably — silently prefixing the visible transcript. The queue preview vanished via the pending-input snapshot refresh, making the message look swallowed. Interactive cores now disable the automatic queued-work driver, making the CLI the sole executor of user-visible next-turn input; there is no enqueue-time driver wake left to race. Catalog refreshes (bootstrap, post-turn, /clear, /resume) enqueue the admin command and drain exactly its returned batch id — a selected-batch drain structurally cannot claim pending turn input, and the helper fails closed if a model turn is unexpectedly reported. Post-turn reconciliation reads the authoritative session read view and durable turn_input_applications instead of the foreground TurnReport, and the live injected-input path is shared with durable reconciliation. The standard-slow-echo fixture now selects its reply from the request's last visible user text so retained history cannot mask a missing turn; a PTY e2e drives the full queue-during-turn flow and asserts request order, both rendered rows, durable completion, and catalog-sync health. Known limitation: with the automatic driver gone, idle process-wake queued work has no interactive executor until the next user turn. No wake producer is reachable in this CLI today (no process-engine plugin); tracked as #13 with a fix sketch over existing facade surfaces. Fixes #12. Release-Notes: messages queued for the next turn now always render and run visibly; previously they could execute invisibly when the post-turn tool-catalog sync claimed them. --- crates/lash-cli/src/app/runtime_events.rs | 7 +- crates/lash-cli/src/config.rs | 15 ++- .../src/interactive/commands/session.rs | 24 ++-- crates/lash-cli/src/interactive/mod.rs | 16 ++- crates/lash-cli/src/interactive/runtime.rs | 12 +- crates/lash-cli/src/startup/mod.rs | 6 +- crates/lash-cli/src/startup/session.rs | 29 +++++ crates/lash-cli/tests/cli_e2e.rs | 116 ++++++++++++++++++ 8 files changed, 194 insertions(+), 31 deletions(-) diff --git a/crates/lash-cli/src/app/runtime_events.rs b/crates/lash-cli/src/app/runtime_events.rs index 072fd1f..ccd1893 100644 --- a/crates/lash-cli/src/app/runtime_events.rs +++ b/crates/lash-cli/src/app/runtime_events.rs @@ -137,7 +137,10 @@ impl App { } } - fn accept_injected_turn_input(&mut self, applications: &[TurnInputApplication]) { + pub(crate) fn reconcile_turn_input_applications( + &mut self, + applications: &[TurnInputApplication], + ) { self.finalize_live_markdown(); let mut accepted_user_message = false; let pending = self.pending_turn_input_snapshot().to_vec(); @@ -480,7 +483,7 @@ impl App { } } TurnEvent::QueuedInputAccepted { applications } => { - self.accept_injected_turn_input(&applications); + self.reconcile_turn_input_applications(&applications); } TurnEvent::QueuedMessagesCommitted { messages, .. } => { self.commit_injected_messages(&messages); diff --git a/crates/lash-cli/src/config.rs b/crates/lash-cli/src/config.rs index e36ca58..0ca8959 100644 --- a/crates/lash-cli/src/config.rs +++ b/crates/lash-cli/src/config.rs @@ -623,13 +623,22 @@ fn standard_slow_echo_provider() -> lash::testing::TestProvider { }) .complete(|request| async move { record_test_provider_request("standard-slow-echo", &request); - let response = if request_contains_text(&request, "queued after escape") { + let last_user_text = request_visible_user_texts(&request) + .pop() + .unwrap_or_default(); + let response = if last_user_text.contains("queued after escape") { "test-provider echo: queued after escape" - } else if request_contains_text(&request, "slow initial prompt") { + } else if last_user_text.contains("slow initial prompt") { tokio::time::sleep(std::time::Duration::from_secs(2)).await; "test-provider echo: slow initial prompt" } else { - "test-provider echo: interactive prompt" + return Ok(lash::provider::LlmResponse { + parts: vec![lash::direct::LlmOutputPart::Text { + text: format!("test-provider echo: {last_user_text}"), + response_meta: None, + }], + ..Default::default() + }); }; Ok(lash::provider::LlmResponse { parts: vec![lash::direct::LlmOutputPart::Text { diff --git a/crates/lash-cli/src/interactive/commands/session.rs b/crates/lash-cli/src/interactive/commands/session.rs index 04a70f9..a0259af 100644 --- a/crates/lash-cli/src/interactive/commands/session.rs +++ b/crates/lash-cli/src/interactive/commands/session.rs @@ -54,12 +54,13 @@ async fn activate_opened_session( return Err("opened session was not installed".to_string()); }; app.set_rlm_dialect(current_rlm_dialect(runtime)); - session - .admin() - .commands() - .refresh_tool_catalog("interactive session open", "interactive-session-open") - .await - .map_err(|err| err.to_string())?; + crate::startup::session::refresh_tool_catalog_and_wait( + session, + "interactive session open", + "interactive-session-open", + ) + .await + .map_err(|err| err.to_string())?; *active_tool_state = session .admin() .tools() @@ -143,11 +144,12 @@ pub(super) async fn handle_clear( }; app.set_rlm_dialect(current_rlm_dialect(runtime)); if let Some(rt) = runtime.as_ref() { - rt.admin() - .commands() - .refresh_tool_catalog("interactive session switch", "interactive-session-switch") - .await - .map_err(|err| anyhow::anyhow!(err.to_string()))?; + crate::startup::session::refresh_tool_catalog_and_wait( + rt, + "interactive session switch", + "interactive-session-switch", + ) + .await?; let session_id = rt.session_id(); app.session_id = session_id; *current_model_variant = crate::model_selection::variant_from_reasoning_selection( diff --git a/crates/lash-cli/src/interactive/mod.rs b/crates/lash-cli/src/interactive/mod.rs index 6a98dfc..67208ad 100644 --- a/crates/lash-cli/src/interactive/mod.rs +++ b/crates/lash-cli/src/interactive/mod.rs @@ -426,9 +426,13 @@ pub(crate) async fn run_app( .usage_report() .usage .usage; - let state = done.result.state; + let rt = runtime + .as_ref() + .expect("runtime remains installed while a turn runs"); + let read_view = rt.read_view(); + let turn_input_applications = rt.turn_input_applications().await?; tracing::info!( - turn_index = state.turn_index, + turn_index = read_view.turn_index(), outcome = ?done.result.outcome, assistant_chars = done.result.assistant_output.safe_text.len(), "runtime turn completed" @@ -456,15 +460,15 @@ pub(crate) async fn run_app( } } - let read_view = state.read_view(); history = read_view.messages().to_vec(); - turn_counter = state.turn_index; + turn_counter = read_view.turn_index(); app.usage.last_response_usage = completed_turn_usage; app.usage.token_usage = session_usage; - app.usage.last_prompt_usage = state.last_prompt_usage.clone(); + app.usage.last_prompt_usage = read_view.last_prompt_usage().cloned(); + app.reconcile_turn_input_applications(&turn_input_applications); tracing::debug!( stream_id = done.stream_id, - turn_index = state.turn_index, + turn_index = read_view.turn_index(), outcome = ?done.result.outcome, messages = read_view.messages().len(), blocks = app.timeline.len(), diff --git a/crates/lash-cli/src/interactive/runtime.rs b/crates/lash-cli/src/interactive/runtime.rs index 3ba2f20..538a394 100644 --- a/crates/lash-cli/src/interactive/runtime.rs +++ b/crates/lash-cli/src/interactive/runtime.rs @@ -69,11 +69,13 @@ pub(super) async fn sync_runtime_tool_catalog( runtime: &mut Option, ) -> Result<(), String> { if let Some(rt) = runtime.as_mut() { - rt.admin() - .commands() - .refresh_tool_catalog("interactive sync", "interactive-sync-runtime-tools") - .await - .map_err(|err| err.to_string())?; + crate::startup::session::refresh_tool_catalog_and_wait( + rt, + "interactive sync", + "interactive-sync-runtime-tools", + ) + .await + .map_err(|err| err.to_string())?; } Ok(()) } diff --git a/crates/lash-cli/src/startup/mod.rs b/crates/lash-cli/src/startup/mod.rs index 52601c7..9596fb3 100644 --- a/crates/lash-cli/src/startup/mod.rs +++ b/crates/lash-cli/src/startup/mod.rs @@ -561,6 +561,7 @@ pub(crate) async fn run(args: Args) -> anyhow::Result<()> { mcp_deferred_resolver, trace_path, trace_level, + !autonomous, ); let session_bootstrap = session_bootstrap.expect("session bootstrap is opened for every non --info run"); @@ -629,10 +630,7 @@ pub(crate) async fn run(args: Args) -> anyhow::Result<()> { let initial_model_variant = initial_policy.model.variant.clone(); let session_name = opened_session.bootstrap.session_name(); let mut logger = opened_session.logger; - session - .admin() - .commands() - .refresh_tool_catalog("bootstrap", "bootstrap-refresh-tool-catalog") + session::refresh_tool_catalog_and_wait(&session, "bootstrap", "bootstrap-refresh-tool-catalog") .await?; if rlm_projected_bindings.is_some() && args.print_prompt.is_none() { return Err(anyhow::anyhow!( diff --git a/crates/lash-cli/src/startup/session.rs b/crates/lash-cli/src/startup/session.rs index cfc11d0..9ccc6b9 100644 --- a/crates/lash-cli/src/startup/session.rs +++ b/crates/lash-cli/src/startup/session.rs @@ -83,6 +83,7 @@ pub(crate) struct CliSessionOpener { deferred_tool_resolver: Option, trace_jsonl_path: Option, trace_level: lash::tracing::TraceLevel, + owns_visible_queued_turns: bool, opened_cores: Arc>>, } @@ -161,6 +162,7 @@ impl CliSessionOpener { deferred_tool_resolver: Option, trace_jsonl_path: Option, trace_level: lash::tracing::TraceLevel, + owns_visible_queued_turns: bool, ) -> Self { Self { plugin_stack, @@ -170,6 +172,7 @@ impl CliSessionOpener { deferred_tool_resolver, trace_jsonl_path, trace_level, + owns_visible_queued_turns, opened_cores: Arc::new(tokio::sync::Mutex::new(Vec::new())), } } @@ -289,6 +292,9 @@ impl CliSessionOpener { .process_registry(process_registry) .trigger_store(trigger_store) .trace_level(self.trace_level); + if self.owns_visible_queued_turns { + builder = builder.disable_queued_work_driver(); + } if let Some(trace_path) = self.trace_jsonl_path.clone() { builder = builder.trace_jsonl_path(trace_path); } @@ -414,3 +420,26 @@ impl CliSessionOpener { .await } } + +pub(crate) async fn refresh_tool_catalog_and_wait( + session: &LashSession, + reason: &str, + idempotency_key: &str, +) -> Result<()> { + let receipt = session + .admin() + .commands() + .refresh_tool_catalog(reason, idempotency_key) + .await?; + let outcome = session + .queued_turn() + .batch_ids([receipt.batch_id.clone()]) + .drain_id(receipt.batch_id) + .run() + .await?; + anyhow::ensure!( + !outcome.executed_selected_turn(), + "tool catalog refresh unexpectedly executed a model turn" + ); + Ok(()) +} diff --git a/crates/lash-cli/tests/cli_e2e.rs b/crates/lash-cli/tests/cli_e2e.rs index 3e46603..9fb095c 100644 --- a/crates/lash-cli/tests/cli_e2e.rs +++ b/crates/lash-cli/tests/cli_e2e.rs @@ -138,6 +138,83 @@ fn cli_interactive_pty_smoke_runs_turn_and_exits() { ); } +#[test] +fn cli_interactive_pty_next_turn_is_visibly_executed_once() { + let lash_home = test_lash_home("standard-slow-echo"); + let mut harness = start_interactive_harness(&lash_home, ExecutionMode::Standard, None); + + harness + .send_line("slow initial prompt") + .expect("send slow initial prompt"); + harness + .wait_for_text("Thinking", Duration::from_secs(10)) + .expect("wait for slow turn to become active"); + harness + .type_text("hold for next turn") + .expect("type queued next-turn input"); + harness.press_key("Tab").expect("queue input for next turn"); + harness + .wait_for_text("Queued for next turn", Duration::from_secs(10)) + .expect("wait for queued next-turn preview"); + harness + .wait_for_text( + "test-provider echo: hold for next turn", + Duration::from_secs(30), + ) + .expect("wait for visible queued-turn response"); + + let run = harness.finish_cleanly().expect("finish harness"); + let provider_requests = provider_request_visible_user_texts(lash_home.path()); + assert_eq!( + provider_requests.len(), + 2, + "queued next-turn input must produce exactly one additional provider request\nrequests:\n{provider_requests:#?}\nscreen:\n{}", + run.screen_text + ); + assert_eq!( + provider_requests[1].last().map(String::as_str), + Some("hold for next turn"), + "request 2 must end with the queued next-turn input\nrequests:\n{provider_requests:#?}" + ); + assert!( + run.screen_text.contains("● hold for next turn") + && run + .screen_text + .contains("■ test-provider echo: hold for next turn"), + "queued user input and its assistant response must both be visible\nscreen:\n{}", + run.screen_text + ); + let trace = + std::fs::read_to_string(&run.artifacts.ui_trace_json).expect("read queued-turn UI trace"); + assert!( + !trace.contains("Tool catalog could not be refreshed"), + "queued-turn ownership must not break post-turn catalog sync\ntrace:\n{trace}" + ); + + let applications = settled_turn_input_applications(lash_home.path()); + let queued_applications = applications + .iter() + .filter(|application| { + application + .source_key + .as_deref() + .is_some_and(|source_key| source_key.starts_with("host:")) + }) + .collect::>(); + assert_eq!( + queued_applications.len(), + 1, + "the queued next-turn input must end completed in one durable turn application\napplications:\n{applications:#?}" + ); + assert!( + queued_applications[0] + .turn_id + .as_str() + .starts_with("cli-queue-drain:"), + "the CLI-owned queued-turn path must commit the queued input\napplications:\n{applications:#?}" + ); +} + #[test] fn cli_interactive_pty_active_steer_escape_restores_editor_without_replay() { let lash_home = test_lash_home("standard-gated-escape"); @@ -660,6 +737,45 @@ fn provider_request_visible_user_texts(lash_home: &std::path::Path) -> Vec Vec { + use lash::persistence::SessionStoreFactory as _; + + let session_id = std::fs::read_dir(lash_home.join("sessions")) + .expect("read session roster") + .filter_map(Result::ok) + .find_map(|entry| { + entry + .file_name() + .to_str() + .and_then(|name| name.strip_suffix(".ui.json")) + .map(str::to_string) + }) + .expect("session roster entry"); + let runtime = tokio::runtime::Runtime::new().expect("test inspection runtime"); + let store_dir = lash_home.join("store"); + let factory = lash_sqlite_store::SqliteSessionStoreFactory::new_with_process_registry( + &store_dir, + store_dir.join("processes.db"), + ); + let read_view = runtime + .block_on(factory.open_read_only(&session_id)) + .expect("open durable session read view") + .expect("durable session exists"); + let store = runtime + .block_on( + factory.create_store(&lash::persistence::SessionStoreCreateRequest { + session_id: session_id.clone(), + relation: read_view.durable_relation().cloned().unwrap_or_default(), + pending_observer_intents: Vec::new(), + policy: read_view.policy().clone(), + }), + ) + .expect("open durable session store"); + runtime + .block_on(store.list_turn_input_applications(&session_id)) + .expect("read settled turn-input applications") +} + fn screen_cell_for(screen: &str, needle: &str) -> Option<(u16, u16)> { screen .lines() From aa0e7f81fbe8dcbe049dddf3288f9d6d454b548f Mon Sep 17 00:00:00 2001 From: Samuel Galanakis Date: Wed, 26 Aug 2026 22:34:29 +0200 Subject: [PATCH 24/25] Tighten runbook gates and fix wording drift from battery round 3 - Refresh the quickstart embedding API sketch. - Require fresh committed markers for queued-turn delivery. - Clear stale capture before the cancellation idle gate. - Match the current Session identity table in the resume picker. - Align subagent dock wording and gate on settled output. Release-Notes: none (runbook documentation only) --- runbooks/cancel-semantics/runbook.md | 12 +++++++----- runbooks/process-dock-lifecycle/runbook.md | 19 +++++++++---------- runbooks/quickstart-walkthrough/runbook.md | 2 +- runbooks/resume-picker/runbook.md | 9 +++++---- runbooks/turn-flow-and-queueing/runbook.md | 20 +++++++++++--------- 5 files changed, 33 insertions(+), 29 deletions(-) diff --git a/runbooks/cancel-semantics/runbook.md b/runbooks/cancel-semantics/runbook.md index 820a385..7d25ee4 100644 --- a/runbooks/cancel-semantics/runbook.md +++ b/runbooks/cancel-semantics/runbook.md @@ -79,14 +79,16 @@ conversation` must be gone.) **Rung 2 — cancel the active turn.** The turn commits to its interrupted terminal state. ``` +clear key ctrl-c -expect 20 Manually interrupted +expect 20 Manually interrupted. expect 10 Idle ``` -Gate: `Manually interrupted.` renders and the footer returns to `Idle`. A turn that keeps -running, or errors instead of interrupting, → Abort/RCA (turn cancel). The `/` draft still -survives. +Gate: after clearing the startup capture, `Manually interrupted.` renders and the footer +returns to `Idle`; the `Idle` match therefore belongs to this cancellation rung rather than +the initial frame. A turn that keeps running, or errors instead of interrupting, → Abort/RCA +(turn cancel). The `/` draft still survives. **Rung 3 — clear the non-empty draft.** The `/` draft is discarded; the idle placeholder returns. @@ -118,7 +120,7 @@ gate. A *clean* exit here is the expected outcome of this rung, not an Abort tri | Item | Objective gate | Verdict | Notes | |------|----------------|---------|-------| | Rung 1: suggestion closes | popup + `Reset conversation` gone; `/` draft + `Thinking` survive | | | -| Rung 2: active turn cancels | `Manually interrupted.` + footer `Idle` | | | +| Rung 2: active turn cancels | post-`clear` `Manually interrupted.` + footer `Idle` | | | | Rung 3: draft clears | `❯ Message · / for commands` returns; process still alive | | | | Rung 4: quit from idle empty | child exits `0` | | | | Order held | exactly four presses, one rung each, none out of order | | | diff --git a/runbooks/process-dock-lifecycle/runbook.md b/runbooks/process-dock-lifecycle/runbook.md index 779c8b4..7a53bc8 100644 --- a/runbooks/process-dock-lifecycle/runbook.md +++ b/runbooks/process-dock-lifecycle/runbook.md @@ -7,9 +7,8 @@ **Purpose.** Prove the CLI process dock's contract: a **Runtime Process** shows in the dock (header `Background`), the focused process can be cancelled (`Delete`), and — the invariant — **ending or deleting a session never ends a process by itself**. The dock uniformly -reflects the session's durable process registry (`session.processes().list()`): a subagent -spawn appears as a transient `subagent` row while it runs (plus a short post-completion -retention window), then is pruned. +reflects the session's durable process registry (`session.processes().list()`). A subagent +spawn is inline tool activity only and does not appear in the dock. **Why this matters.** The Lash repository's CONTEXT.md → "Runtime Process" says its lifecycle is independent of any session and only runtime processes appear in the CLI @@ -25,11 +24,10 @@ by dry-run against every deterministic scenario: /fork /tree /version /info /model /variant /mode /provider /logout /retry /resume /skills /help /exit` (`crates/lash-cli/src/command.rs`). The dock is a render surface fed by `session.processes().list()`, not a command. -- The RLM `rlm-subagent-smoke` scenario **does** spawn a subagent. It renders **both** as - an inline tool activity — `◆ spawn subagent · …` with footer `Running tool · - spawn_agent` — and as a transient `Background` dock row (`◆ running · subagent · spawn`) - backed by a durable `processes.db` record, pruned shortly after completion. It is not a - long-lived Runtime Process: it cannot witness the deletion invariant. +- The RLM `rlm-subagent-smoke` scenario **does** spawn a subagent. It renders only as + inline tool activity — `◆ spawn subagent · …` with footer `Running tool · + spawn_agent`. It never raises a `Background` dock row and is not a durable Runtime + Process, so it cannot witness the deletion invariant. - The deterministic test provider only returns canned responses, so no scenario leaves a durable Runtime Process running; the dock stays empty (`app.processes.is_empty()` → `crates/lash-cli/src/render/sections/docks.rs` draws nothing). A **Process Engine** that @@ -66,7 +64,8 @@ confirm the deterministic provider and idle prompt. type Does your subagent tool work key enter expect 15 Running tool -expect 45 subagent-ok +clear +expect 45 ■ subagent-ok screen 40 ``` @@ -123,7 +122,7 @@ tokens — deliberate). | Item | Objective gate | Verdict | Notes | |------|----------------|---------|-------| -| Subagent is a tool activity, not a dock process | `◆ spawn subagent` + `Running tool`, no `Background` dock | | | +| Subagent is a tool activity, not a dock process | `◆ spawn subagent` + `Running tool`, then post-`clear` `■ subagent-ok`; no `Background` dock | | | | Empty dock has no focus target | `Tab` opens no overview; no `Background` header | | | | Dock process visible *(real only)* | `Background` row `◆ running · … `; process store row | | | | Cancel path *(real only)* | `running → cancelled`; terminal state in store | | | diff --git a/runbooks/quickstart-walkthrough/runbook.md b/runbooks/quickstart-walkthrough/runbook.md index 46af739..e2b7e17 100644 --- a/runbooks/quickstart-walkthrough/runbook.md +++ b/runbooks/quickstart-walkthrough/runbook.md @@ -50,7 +50,7 @@ and `Cargo.toml` exist there; confirm the operator exists in this repository. | # | Page claim | Gate | Expected | |---|-----------|------|----------| | 1 | Crate is published as `lash-runtime`; the Rust import stays `use lash::...` | Lash repository `Cargo.toml` alias | `lash = { package = "lash-runtime", … }` | -| 2 | The "Minimal Example" code (`standard_builder → provider → model(from_token_limits) → effect_host(InlineEffectHost) → attachment_store(InMemoryAttachmentStore) → build`; `core.session(..).open()`; `session.turn(TurnInput::text(..)).run()`; `result.assistant_message()`) | In the Lash repository, `python3 scripts/lint_docs.py` (the page's `data-snippet="quickstart#hello-lash"` is diffed against `examples/docs-snippets/src/quickstart.rs`) and the snippet crate's compile check against the pinned façade | `docs lint: ok`; snippet compiles against the pinned façade API | +| 2 | The "Minimal Example" code (`standard_builder(TurnBudget::bounded(..)) → provider → model → effect_host(InlineEffectHost) → attachment_store(InMemoryAttachmentStore) → process_env_store(InMemoryProcessExecutionEnvStore) → commit_budget → queued_work_batching → build`; `core.session(..).open()`; `session.turn(TurnInput::text(..)).run()`; `result.assistant_message()`) | In the Lash repository, `python3 scripts/lint_docs.py` (the page's `data-snippet="quickstart#hello-lash"` is diffed against `examples/docs-snippets/src/quickstart.rs`) and the snippet crate's compile check against the pinned façade | `docs lint: ok`; snippet compiles against the pinned façade API | Run `python3 scripts/lint_docs.py` in the Lash repository — a clean `docs lint: ok` is the objective gate that the snippet and page structure haven't drifted from the compiled diff --git a/runbooks/resume-picker/runbook.md b/runbooks/resume-picker/runbook.md index 937aace..cfa3fd9 100644 --- a/runbooks/resume-picker/runbook.md +++ b/runbooks/resume-picker/runbook.md @@ -54,13 +54,14 @@ expect 10 Started new session ``` You are now in an **empty** session (call it B) with the non-empty session A persisted. Read -B's catalog identity before rolling past it — open `/info` and record `session id: ` -from the **Session** section: +B's catalog identity before rolling past it: open `/info`, gate on the **Session** section, +then record the separate `name ` and `id ` rows: ``` type /info key enter -expect 10 session id +expect 10 Session +expect 10 id screen 40 key esc ``` @@ -150,7 +151,7 @@ thing to resume. Then `kill`. | Current session excluded | current session absent from the list | | | | Direct target reaches hidden session | `Resumed: ` | | | | Only-empty shows all | every row `No messages yet` | | | -| Identifier surface (rigor) | name/id resolves, or reported as a doc/CLI divergence | | | +| Identifier surface (rigor) | `/info` renders `Session`, then separate `name ` and `id ` rows | | | **Aggregate:** does the picker hide zero-turn sessions only when a non-empty exists, show `No messages yet` when only empties exist, exclude the current session, and does the direct diff --git a/runbooks/turn-flow-and-queueing/runbook.md b/runbooks/turn-flow-and-queueing/runbook.md index d87046a..9bf0500 100644 --- a/runbooks/turn-flow-and-queueing/runbook.md +++ b/runbooks/turn-flow-and-queueing/runbook.md @@ -112,17 +112,19 @@ expect 10 slow initial prompt type hold for next turn key tab expect 8 ◇ Queued for next turn -expect 20 test-provider echo: slow initial prompt -expect 20 hold for next turn +expect 20 ■ test-provider echo: slow initial prompt +clear +expect 20 ● hold for next turn +expect 20 ■ test-provider echo: hold for next turn ``` Gate: after the first turn settles (`■ test-provider echo: slow initial prompt`), the -queued draft becomes its own committed user turn — a `● hold for next turn` row below the -turn separator, followed by a fresh turn that runs to `Idle`. (Under rolling history the -second turn's request still contains `slow initial prompt`, so the deterministic echo is -again `test-provider echo: slow initial prompt`; the delivery gate is the new `● hold for -next turn` turn, not the echo wording.) The queued draft never firing → Abort/RCA -(queued-turn dispatch). Then `lash-exit 10`. +capture is cleared so the already-rendered `↳ hold for next turn` queue-preview child row +cannot pass the delivery gate. The queued draft must then become its own committed user turn +— exact row `● hold for next turn` below the turn separator — and settle as exact response +`■ test-provider echo: hold for next turn`. The `↳` preview proves only that the draft was +queued; it is not delivery evidence. Either committed marker failing to render → Abort/RCA +(queued-turn dispatch or settlement). Then `lash-exit 10`. ## Phase 4 — Slow-turn generation-fence regression (`standard-slow-echo`) @@ -176,7 +178,7 @@ does not pass this phase. | Early Injection label | `◆ Will send in this turn` + `↳ inject this now` | | | | Next Full Turn label | `◇ Queued for next turn` + `↳ hold for next turn` | | | | Ingress recorded correctly | trace: 1× `queue_current_turn_input`, 1× `queue_turn` | | | -| Next-turn draft delivers | new `● hold for next turn` committed turn after commit | | | +| Next-turn draft delivers | after `clear`, exact `● hold for next turn`, then `■ test-provider echo: hold for next turn`; `↳` preview is insufficient | | | | Slow-turn rows commit | `● slow initial prompt`, `● generation fence follow-up`, and `■ test-provider echo: slow initial prompt` rendered; footer returned idle | | | | Slow-turn ingress delivers once | trace: 1× `queue_current_turn_input`, 0× `queue_turn`; provider log: 1× `generation fence follow-up` | | | | Slow-turn claim stays live | final screen contains no `Superseded` or turn-failure text | | | From 92034976ac6a81a7ccde7503755a1e38e9638c04 Mon Sep 17 00:00:00 2001 From: Samuel Galanakis <47306720+SamGalanakis@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:35:50 +0200 Subject: [PATCH 25/25] Clear stale capture before the resume-picker identity gate The Session/id gates otherwise match pre-overlay capture such as the footer Idle stamp; a fresh capture confines them to the /info frame. Release-Notes: none (runbook documentation only) --- runbooks/resume-picker/runbook.md | 1 + 1 file changed, 1 insertion(+) diff --git a/runbooks/resume-picker/runbook.md b/runbooks/resume-picker/runbook.md index cfa3fd9..693144c 100644 --- a/runbooks/resume-picker/runbook.md +++ b/runbooks/resume-picker/runbook.md @@ -58,6 +58,7 @@ B's catalog identity before rolling past it: open `/info`, gate on the **Session then record the separate `name ` and `id ` rows: ``` +clear type /info key enter expect 10 Session