From 4fd172a49abb227e0076b2d06f41343f53641e56 Mon Sep 17 00:00:00 2001 From: Samuel Galanakis Date: Tue, 8 Sep 2026 10:47:00 +0200 Subject: [PATCH 1/7] Adopt Lash revision 46dfed8 and typed runtime evidence Carry process incarnation and event freshness through dock state and selection. Preserve explicit queued-work ownership, handle fallible RLM configuration, retain attachment capability snapshots, and render typed cell failures, advisory issues, omitted records, and charge-refusal evidence. CLI stream IDs restart on reopen, so using them as durable turn or drain identities caused effect replay hash conflicts. Use fresh durable identities while cancellation continues to target the observed TurnStarted ID. Verify catalog refresh leaves pending user input and unrelated work untouched. Release-Notes: Update the Lash runtime pin and adopt its process, capability, and failure-report contracts; prevent replay identity collisions on resume. --- Cargo.lock | 138 ++++------------ Cargo.toml | 38 ++--- crates/lash-cli/src/app/mod.rs | 91 +++++++++-- crates/lash-cli/src/app/runtime_events.rs | 4 +- .../lash-cli/src/app/runtime_events_tests.rs | 22 +++ crates/lash-cli/src/app/view.rs | 4 +- crates/lash-cli/src/autonomous.rs | 52 +++++- crates/lash-cli/src/capability_catalog.rs | 154 +++++++++++++++++- .../lash-cli/src/interactive/commands/mod.rs | 7 +- .../src/interactive/commands/session.rs | 13 +- crates/lash-cli/src/interactive/mod.rs | 3 + crates/lash-cli/src/mcp_discovery/rerank.rs | 1 - .../lash-cli/src/render/compositor/tests.rs | 4 + crates/lash-cli/src/render/sections/docks.rs | 2 +- crates/lash-cli/src/startup/plugins.rs | 27 ++- crates/lash-cli/src/startup/session.rs | 2 +- crates/lash-cli/src/turn_runner.rs | 68 +++++++- crates/lash-cli/src/ui_effects.rs | 7 +- crates/lash-cli/src/util.rs | 53 ++++++ crates/lash-export/src/lib.rs | 15 +- crates/lash-export/src/trace.rs | 3 +- 21 files changed, 515 insertions(+), 193 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 83317fc..1c57787 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -287,16 +287,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "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", + "bit-vec", ] [[package]] @@ -305,12 +296,6 @@ 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" @@ -1002,7 +987,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" dependencies = [ - "bit-set 0.5.3", + "bit-set", "regex-automata", "regex-syntax", ] @@ -2186,7 +2171,7 @@ dependencies = [ [[package]] name = "lash-internal-core" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=9debf5aab75baa3074c27cf42563e169a79ec396#9debf5aab75baa3074c27cf42563e169a79ec396" +source = "git+https://github.com/Ascending-AI/lash.git?rev=46dfed8ea406738588cb6a170a1d6825154267cd#46dfed8ea406738588cb6a170a1d6825154267cd" dependencies = [ "arc-swap", "async-trait", @@ -2196,7 +2181,6 @@ dependencies = [ "lash-internal-http-transport", "lash-internal-sansio", "lash-internal-trace", - "proptest", "rmp-serde", "schemars", "serde", @@ -2213,7 +2197,7 @@ dependencies = [ [[package]] name = "lash-internal-http-transport" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=9debf5aab75baa3074c27cf42563e169a79ec396#9debf5aab75baa3074c27cf42563e169a79ec396" +source = "git+https://github.com/Ascending-AI/lash.git?rev=46dfed8ea406738588cb6a170a1d6825154267cd#46dfed8ea406738588cb6a170a1d6825154267cd" dependencies = [ "async-trait", "bytes", @@ -2227,7 +2211,7 @@ dependencies = [ [[package]] name = "lash-internal-lashlang" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=9debf5aab75baa3074c27cf42563e169a79ec396#9debf5aab75baa3074c27cf42563e169a79ec396" +source = "git+https://github.com/Ascending-AI/lash.git?rev=46dfed8ea406738588cb6a170a1d6825154267cd#46dfed8ea406738588cb6a170a1d6825154267cd" dependencies = [ "async-trait", "compact_str 0.9.1", @@ -2251,7 +2235,7 @@ dependencies = [ [[package]] name = "lash-internal-lashlang-runtime" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=9debf5aab75baa3074c27cf42563e169a79ec396#9debf5aab75baa3074c27cf42563e169a79ec396" +source = "git+https://github.com/Ascending-AI/lash.git?rev=46dfed8ea406738588cb6a170a1d6825154267cd#46dfed8ea406738588cb6a170a1d6825154267cd" dependencies = [ "async-trait", "chrono", @@ -2272,7 +2256,7 @@ dependencies = [ [[package]] name = "lash-internal-llm-tools" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=9debf5aab75baa3074c27cf42563e169a79ec396#9debf5aab75baa3074c27cf42563e169a79ec396" +source = "git+https://github.com/Ascending-AI/lash.git?rev=46dfed8ea406738588cb6a170a1d6825154267cd#46dfed8ea406738588cb6a170a1d6825154267cd" dependencies = [ "async-trait", "jsonschema", @@ -2286,7 +2270,7 @@ dependencies = [ [[package]] name = "lash-internal-llm-transport" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=9debf5aab75baa3074c27cf42563e169a79ec396#9debf5aab75baa3074c27cf42563e169a79ec396" +source = "git+https://github.com/Ascending-AI/lash.git?rev=46dfed8ea406738588cb6a170a1d6825154267cd#46dfed8ea406738588cb6a170a1d6825154267cd" dependencies = [ "async-trait", "bytes", @@ -2300,7 +2284,7 @@ dependencies = [ [[package]] name = "lash-internal-plugin-mcp" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=9debf5aab75baa3074c27cf42563e169a79ec396#9debf5aab75baa3074c27cf42563e169a79ec396" +source = "git+https://github.com/Ascending-AI/lash.git?rev=46dfed8ea406738588cb6a170a1d6825154267cd#46dfed8ea406738588cb6a170a1d6825154267cd" dependencies = [ "async-trait", "base64 0.22.1", @@ -2324,7 +2308,7 @@ dependencies = [ [[package]] name = "lash-internal-plugin-process-controls" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=9debf5aab75baa3074c27cf42563e169a79ec396#9debf5aab75baa3074c27cf42563e169a79ec396" +source = "git+https://github.com/Ascending-AI/lash.git?rev=46dfed8ea406738588cb6a170a1d6825154267cd#46dfed8ea406738588cb6a170a1d6825154267cd" dependencies = [ "async-trait", "lash-internal-core", @@ -2336,7 +2320,7 @@ dependencies = [ [[package]] name = "lash-internal-plugin-tool-output-budget" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=9debf5aab75baa3074c27cf42563e169a79ec396#9debf5aab75baa3074c27cf42563e169a79ec396" +source = "git+https://github.com/Ascending-AI/lash.git?rev=46dfed8ea406738588cb6a170a1d6825154267cd#46dfed8ea406738588cb6a170a1d6825154267cd" dependencies = [ "lash-internal-core", "lash-internal-protocol-standard", @@ -2348,7 +2332,7 @@ dependencies = [ [[package]] name = "lash-internal-protocol-rlm" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=9debf5aab75baa3074c27cf42563e169a79ec396#9debf5aab75baa3074c27cf42563e169a79ec396" +source = "git+https://github.com/Ascending-AI/lash.git?rev=46dfed8ea406738588cb6a170a1d6825154267cd#46dfed8ea406738588cb6a170a1d6825154267cd" dependencies = [ "async-trait", "jsonschema", @@ -2357,6 +2341,7 @@ dependencies = [ "lash-internal-lashlang-runtime", "lash-internal-rlm-types", "lash-internal-sansio", + "lash-internal-trace", "lash-internal-typescript", "rmp-serde", "serde", @@ -2371,7 +2356,7 @@ dependencies = [ [[package]] name = "lash-internal-protocol-standard" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=9debf5aab75baa3074c27cf42563e169a79ec396#9debf5aab75baa3074c27cf42563e169a79ec396" +source = "git+https://github.com/Ascending-AI/lash.git?rev=46dfed8ea406738588cb6a170a1d6825154267cd#46dfed8ea406738588cb6a170a1d6825154267cd" dependencies = [ "async-trait", "lash-internal-core", @@ -2383,7 +2368,7 @@ dependencies = [ [[package]] name = "lash-internal-provider-anthropic" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=9debf5aab75baa3074c27cf42563e169a79ec396#9debf5aab75baa3074c27cf42563e169a79ec396" +source = "git+https://github.com/Ascending-AI/lash.git?rev=46dfed8ea406738588cb6a170a1d6825154267cd#46dfed8ea406738588cb6a170a1d6825154267cd" dependencies = [ "async-trait", "base64 0.22.1", @@ -2399,7 +2384,7 @@ dependencies = [ [[package]] name = "lash-internal-provider-auth" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=9debf5aab75baa3074c27cf42563e169a79ec396#9debf5aab75baa3074c27cf42563e169a79ec396" +source = "git+https://github.com/Ascending-AI/lash.git?rev=46dfed8ea406738588cb6a170a1d6825154267cd#46dfed8ea406738588cb6a170a1d6825154267cd" dependencies = [ "async-trait", "base64 0.22.1", @@ -2417,7 +2402,7 @@ dependencies = [ [[package]] name = "lash-internal-provider-google" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=9debf5aab75baa3074c27cf42563e169a79ec396#9debf5aab75baa3074c27cf42563e169a79ec396" +source = "git+https://github.com/Ascending-AI/lash.git?rev=46dfed8ea406738588cb6a170a1d6825154267cd#46dfed8ea406738588cb6a170a1d6825154267cd" dependencies = [ "async-trait", "base64 0.22.1", @@ -2435,7 +2420,7 @@ dependencies = [ [[package]] name = "lash-internal-provider-openai" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=9debf5aab75baa3074c27cf42563e169a79ec396#9debf5aab75baa3074c27cf42563e169a79ec396" +source = "git+https://github.com/Ascending-AI/lash.git?rev=46dfed8ea406738588cb6a170a1d6825154267cd#46dfed8ea406738588cb6a170a1d6825154267cd" dependencies = [ "async-trait", "base64 0.22.1", @@ -2457,7 +2442,7 @@ dependencies = [ [[package]] name = "lash-internal-remote-protocol" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=9debf5aab75baa3074c27cf42563e169a79ec396#9debf5aab75baa3074c27cf42563e169a79ec396" +source = "git+https://github.com/Ascending-AI/lash.git?rev=46dfed8ea406738588cb6a170a1d6825154267cd#46dfed8ea406738588cb6a170a1d6825154267cd" dependencies = [ "base64 0.22.1", "lash-internal-core", @@ -2471,7 +2456,7 @@ dependencies = [ [[package]] name = "lash-internal-rlm-types" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=9debf5aab75baa3074c27cf42563e169a79ec396#9debf5aab75baa3074c27cf42563e169a79ec396" +source = "git+https://github.com/Ascending-AI/lash.git?rev=46dfed8ea406738588cb6a170a1d6825154267cd#46dfed8ea406738588cb6a170a1d6825154267cd" dependencies = [ "lash-internal-sansio", "serde", @@ -2481,7 +2466,7 @@ dependencies = [ [[package]] name = "lash-internal-sansio" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=9debf5aab75baa3074c27cf42563e169a79ec396#9debf5aab75baa3074c27cf42563e169a79ec396" +source = "git+https://github.com/Ascending-AI/lash.git?rev=46dfed8ea406738588cb6a170a1d6825154267cd#46dfed8ea406738588cb6a170a1d6825154267cd" dependencies = [ "blake3", "jsonschema", @@ -2493,7 +2478,7 @@ dependencies = [ [[package]] name = "lash-internal-sqlite-store" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=9debf5aab75baa3074c27cf42563e169a79ec396#9debf5aab75baa3074c27cf42563e169a79ec396" +source = "git+https://github.com/Ascending-AI/lash.git?rev=46dfed8ea406738588cb6a170a1d6825154267cd#46dfed8ea406738588cb6a170a1d6825154267cd" dependencies = [ "async-trait", "flate2", @@ -2516,7 +2501,7 @@ dependencies = [ [[package]] name = "lash-internal-standard-plugins" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=9debf5aab75baa3074c27cf42563e169a79ec396#9debf5aab75baa3074c27cf42563e169a79ec396" +source = "git+https://github.com/Ascending-AI/lash.git?rev=46dfed8ea406738588cb6a170a1d6825154267cd#46dfed8ea406738588cb6a170a1d6825154267cd" dependencies = [ "async-trait", "lash-internal-core", @@ -2530,7 +2515,7 @@ dependencies = [ [[package]] name = "lash-internal-subagents" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=9debf5aab75baa3074c27cf42563e169a79ec396#9debf5aab75baa3074c27cf42563e169a79ec396" +source = "git+https://github.com/Ascending-AI/lash.git?rev=46dfed8ea406738588cb6a170a1d6825154267cd#46dfed8ea406738588cb6a170a1d6825154267cd" dependencies = [ "async-trait", "lash-internal-core", @@ -2547,7 +2532,7 @@ dependencies = [ [[package]] name = "lash-internal-tool-support" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=9debf5aab75baa3074c27cf42563e169a79ec396#9debf5aab75baa3074c27cf42563e169a79ec396" +source = "git+https://github.com/Ascending-AI/lash.git?rev=46dfed8ea406738588cb6a170a1d6825154267cd#46dfed8ea406738588cb6a170a1d6825154267cd" dependencies = [ "async-trait", "lash-internal-core", @@ -2560,7 +2545,7 @@ dependencies = [ [[package]] name = "lash-internal-tools" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=9debf5aab75baa3074c27cf42563e169a79ec396#9debf5aab75baa3074c27cf42563e169a79ec396" +source = "git+https://github.com/Ascending-AI/lash.git?rev=46dfed8ea406738588cb6a170a1d6825154267cd#46dfed8ea406738588cb6a170a1d6825154267cd" dependencies = [ "async-trait", "lash-internal-core", @@ -2580,7 +2565,7 @@ dependencies = [ [[package]] name = "lash-internal-trace" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=9debf5aab75baa3074c27cf42563e169a79ec396#9debf5aab75baa3074c27cf42563e169a79ec396" +source = "git+https://github.com/Ascending-AI/lash.git?rev=46dfed8ea406738588cb6a170a1d6825154267cd#46dfed8ea406738588cb6a170a1d6825154267cd" dependencies = [ "chrono", "lash-internal-sansio", @@ -2594,7 +2579,7 @@ dependencies = [ [[package]] name = "lash-internal-typescript" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=9debf5aab75baa3074c27cf42563e169a79ec396#9debf5aab75baa3074c27cf42563e169a79ec396" +source = "git+https://github.com/Ascending-AI/lash.git?rev=46dfed8ea406738588cb6a170a1d6825154267cd#46dfed8ea406738588cb6a170a1d6825154267cd" dependencies = [ "lash-internal-lashlang", "ryu-js", @@ -2607,7 +2592,7 @@ dependencies = [ [[package]] name = "lash-regress" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=9debf5aab75baa3074c27cf42563e169a79ec396#9debf5aab75baa3074c27cf42563e169a79ec396" +source = "git+https://github.com/Ascending-AI/lash.git?rev=46dfed8ea406738588cb6a170a1d6825154267cd#46dfed8ea406738588cb6a170a1d6825154267cd" dependencies = [ "hashbrown 0.16.1", "memchr", @@ -2616,7 +2601,7 @@ dependencies = [ [[package]] name = "lash-runtime" version = "0.0.0-dev" -source = "git+https://github.com/Ascending-AI/lash.git?rev=9debf5aab75baa3074c27cf42563e169a79ec396#9debf5aab75baa3074c27cf42563e169a79ec396" +source = "git+https://github.com/Ascending-AI/lash.git?rev=46dfed8ea406738588cb6a170a1d6825154267cd#46dfed8ea406738588cb6a170a1d6825154267cd" dependencies = [ "async-trait", "futures-util", @@ -3508,25 +3493,6 @@ 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" @@ -3552,12 +3518,6 @@ 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" @@ -3721,15 +3681,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "rand_xorshift" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" -dependencies = [ - "rand_core 0.9.5", -] - [[package]] name = "raw-cpuid" version = "11.6.0" @@ -4085,18 +4036,6 @@ 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" @@ -4785,7 +4724,7 @@ dependencies = [ "fax", "flate2", "half", - "quick-error 2.0.1", + "quick-error", "weezl", "zune-jpeg", ] @@ -5194,12 +5133,6 @@ 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" @@ -5344,15 +5277,6 @@ 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 42a428a..1a476fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,26 +49,26 @@ 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 = "9debf5aab75baa3074c27cf42563e169a79ec396" } -lash-lashlang-runtime = { package = "lash-internal-lashlang-runtime", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" } -lash-llm-tools = { package = "lash-internal-llm-tools", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" } -lash-plugin-mcp = { package = "lash-internal-plugin-mcp", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" } +lash = { package = "lash-runtime", git = "https://github.com/Ascending-AI/lash.git", rev = "46dfed8ea406738588cb6a170a1d6825154267cd" } +lash-lashlang-runtime = { package = "lash-internal-lashlang-runtime", git = "https://github.com/Ascending-AI/lash.git", rev = "46dfed8ea406738588cb6a170a1d6825154267cd" } +lash-llm-tools = { package = "lash-internal-llm-tools", git = "https://github.com/Ascending-AI/lash.git", rev = "46dfed8ea406738588cb6a170a1d6825154267cd" } +lash-plugin-mcp = { package = "lash-internal-plugin-mcp", git = "https://github.com/Ascending-AI/lash.git", rev = "46dfed8ea406738588cb6a170a1d6825154267cd" } # lash-plugin-plan-mode removed upstream -lash-plugin-process-controls = { package = "lash-internal-plugin-process-controls", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" } -lash-protocol-rlm = { package = "lash-internal-protocol-rlm", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" } -lash-protocol-standard = { package = "lash-internal-protocol-standard", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" } -lash-provider-anthropic = { package = "lash-internal-provider-anthropic", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" } -lash-provider-auth = { package = "lash-internal-provider-auth", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" } -lash-provider-google = { package = "lash-internal-provider-google", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" } -lash-provider-openai = { package = "lash-internal-provider-openai", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" } -lash-rlm-types = { package = "lash-internal-rlm-types", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" } -lash-sqlite-store = { package = "lash-internal-sqlite-store", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" } -lash-standard-plugins = { package = "lash-internal-standard-plugins", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" } -lash-subagents = { package = "lash-internal-subagents", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" } -lash-tool-support = { package = "lash-internal-tool-support", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" } -lash-trace = { package = "lash-internal-trace", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" } -lash-typescript = { package = "lash-internal-typescript", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" } -lashlang = { package = "lash-internal-lashlang", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" } +lash-plugin-process-controls = { package = "lash-internal-plugin-process-controls", git = "https://github.com/Ascending-AI/lash.git", rev = "46dfed8ea406738588cb6a170a1d6825154267cd" } +lash-protocol-rlm = { package = "lash-internal-protocol-rlm", git = "https://github.com/Ascending-AI/lash.git", rev = "46dfed8ea406738588cb6a170a1d6825154267cd" } +lash-protocol-standard = { package = "lash-internal-protocol-standard", git = "https://github.com/Ascending-AI/lash.git", rev = "46dfed8ea406738588cb6a170a1d6825154267cd" } +lash-provider-anthropic = { package = "lash-internal-provider-anthropic", git = "https://github.com/Ascending-AI/lash.git", rev = "46dfed8ea406738588cb6a170a1d6825154267cd" } +lash-provider-auth = { package = "lash-internal-provider-auth", git = "https://github.com/Ascending-AI/lash.git", rev = "46dfed8ea406738588cb6a170a1d6825154267cd" } +lash-provider-google = { package = "lash-internal-provider-google", git = "https://github.com/Ascending-AI/lash.git", rev = "46dfed8ea406738588cb6a170a1d6825154267cd" } +lash-provider-openai = { package = "lash-internal-provider-openai", git = "https://github.com/Ascending-AI/lash.git", rev = "46dfed8ea406738588cb6a170a1d6825154267cd" } +lash-rlm-types = { package = "lash-internal-rlm-types", git = "https://github.com/Ascending-AI/lash.git", rev = "46dfed8ea406738588cb6a170a1d6825154267cd" } +lash-sqlite-store = { package = "lash-internal-sqlite-store", git = "https://github.com/Ascending-AI/lash.git", rev = "46dfed8ea406738588cb6a170a1d6825154267cd" } +lash-standard-plugins = { package = "lash-internal-standard-plugins", git = "https://github.com/Ascending-AI/lash.git", rev = "46dfed8ea406738588cb6a170a1d6825154267cd" } +lash-subagents = { package = "lash-internal-subagents", git = "https://github.com/Ascending-AI/lash.git", rev = "46dfed8ea406738588cb6a170a1d6825154267cd" } +lash-tool-support = { package = "lash-internal-tool-support", git = "https://github.com/Ascending-AI/lash.git", rev = "46dfed8ea406738588cb6a170a1d6825154267cd" } +lash-trace = { package = "lash-internal-trace", git = "https://github.com/Ascending-AI/lash.git", rev = "46dfed8ea406738588cb6a170a1d6825154267cd" } +lash-typescript = { package = "lash-internal-typescript", git = "https://github.com/Ascending-AI/lash.git", rev = "46dfed8ea406738588cb6a170a1d6825154267cd" } +lashlang = { package = "lash-internal-lashlang", git = "https://github.com/Ascending-AI/lash.git", rev = "46dfed8ea406738588cb6a170a1d6825154267cd" } lash-autoresearch = { path = "crates/lash-autoresearch" } lash-cli-perf = { path = "crates/lash-cli-perf" } diff --git a/crates/lash-cli/src/app/mod.rs b/crates/lash-cli/src/app/mod.rs index 4df56a4..9679054 100644 --- a/crates/lash-cli/src/app/mod.rs +++ b/crates/lash-cli/src/app/mod.rs @@ -48,6 +48,7 @@ const PROCESS_RETENTION: std::time::Duration = std::time::Duration::from_secs(10 pub(crate) struct ProcessSnapshot { pub view: ProcessHandleView, pub updated_at_ms: Option, + pub last_event_sequence: u64, } fn process_updated_within_retention(updated_at_ms: u64, now_ms: u64) -> bool { @@ -130,6 +131,8 @@ pub struct ToastState { #[derive(Clone, Debug, PartialEq)] pub struct ProcessView { pub process_id: String, + pub incarnation: lash::process::ProcessIncarnation, + pub last_event_sequence: u64, pub kind: String, pub label: String, pub definition: Option, @@ -140,8 +143,13 @@ pub struct ProcessView { } impl ProcessView { + pub fn key(&self) -> lash::process::ProcessRef { + lash::process::ProcessRef::new(self.process_id.clone(), self.incarnation) + } + fn from_summary( summary: ProcessHandleView, + last_event_sequence: u64, first_seen: std::time::Instant, status_duration: Option, transient_until: Option, @@ -159,6 +167,8 @@ impl ProcessView { }); Self { process_id: summary.process_id.to_string(), + incarnation: summary.incarnation, + last_event_sequence, kind, label, definition, @@ -649,7 +659,7 @@ pub struct App { /// Snapshot of processes registered for this session. pub processes: Vec, /// Focused background process row in the trailing process dock. - pub selected_process_id: Option, + pub selected_process_ref: Option, /// UI extension registry used for slash-command completion and host actions. ui_extensions: Arc, /// Shared state for the lash-cli chrome UI extension. The scratch-tui @@ -829,13 +839,13 @@ impl App { let before_tasks = self.processes.len(); self.processes.retain(ProcessView::is_visible); if self.processes.len() != before_tasks { - if self.selected_process_id.as_deref().is_some_and(|selected| { + if self.selected_process_ref.as_ref().is_some_and(|selected| { !self .processes .iter() - .any(|process| process.process_id == selected) + .any(|process| process.key() == *selected) }) { - self.selected_process_id = None; + self.selected_process_ref = None; } self.invalidate_height_cache(); self.dirty = true; @@ -927,7 +937,7 @@ impl App { plugin_mode_indicators: BTreeMap::new(), plan_dock: None, processes: Vec::new(), - selected_process_id: None, + selected_process_ref: None, ui_extensions: Arc::new(TuiExtensions::default()), chrome_state: Arc::new(Mutex::new(crate::chrome_ui::ChromeUiState::default())), cwd, @@ -1072,19 +1082,29 @@ impl App { let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |duration| duration.as_millis() as u64); - let previous: HashMap = self + let previous: HashMap = self .processes .iter() .cloned() - .map(|task| (task.process_id.clone(), task)) + .map(|task| (task.key(), task)) .collect(); let mut next = Vec::new(); for snapshot in snapshots { let ProcessSnapshot { view: task, updated_at_ms, + last_event_sequence, } = snapshot; - let previous_process = previous.get(&task.process_id); + let previous_process = previous.get(&lash::process::ProcessRef::new( + task.process_id.clone(), + task.incarnation, + )); + if let Some(previous_process) = previous_process + && last_event_sequence < previous_process.last_event_sequence + { + next.push(previous_process.clone()); + continue; + } let status = task.status.is_terminal().then_some(task.status); let first_seen = previous_process.map(|item| item.first_seen).unwrap_or(now); let status_duration = if status.is_some() { @@ -1109,16 +1129,17 @@ impl App { }; next.push(ProcessView::from_summary( task, + last_event_sequence, first_seen, status_duration, transient_until, )); } next.retain(ProcessView::is_visible); - if let Some(selected) = self.selected_process_id.as_deref() - && !next.iter().any(|process| process.process_id == selected) + if let Some(selected) = self.selected_process_ref.as_ref() + && !next.iter().any(|process| process.key() == *selected) { - self.selected_process_id = None; + self.selected_process_ref = None; self.dirty = true; } if self.processes != next { @@ -1158,14 +1179,14 @@ impl App { } pub fn selected_process(&self) -> Option<&ProcessView> { - let selected = self.selected_process_id.as_deref()?; + let selected = self.selected_process_ref.as_ref()?; self.processes .iter() - .find(|process| process.process_id == selected) + .find(|process| process.key() == *selected) } pub fn clear_process_selection(&mut self) { - if self.selected_process_id.take().is_some() { + if self.selected_process_ref.take().is_some() { self.dirty = true; } } @@ -1183,10 +1204,10 @@ impl App { self.clear_process_selection(); return false; } - let current = self.selected_process_id.as_deref().and_then(|selected| { + let current = self.selected_process_ref.as_ref().and_then(|selected| { self.processes .iter() - .position(|process| process.process_id == selected) + .position(|process| process.key() == *selected) }); let len = self.processes.len() as isize; let next = match current { @@ -1194,7 +1215,7 @@ impl App { None if delta < 0 => self.processes.len() - 1, None => 0, }; - self.selected_process_id = Some(self.processes[next].process_id.clone()); + self.selected_process_ref = Some(self.processes[next].key()); self.dirty = true; true } @@ -1253,13 +1274,47 @@ mod process_retention_tests { ProcessSnapshot { view: ProcessHandleView::new( process_id, + lash::process::ProcessIncarnation::from_registration_sequence(1), lash::process::ProcessIdentity::new("subagent").with_label(Some("spawn")), status, ), updated_at_ms, + last_event_sequence: 1, } } + #[test] + fn reused_process_id_does_not_inherit_selection_or_terminal_retention() { + let mut app = App::new("model".into(), "provider".into(), "session".into()); + app.update_processes(vec![process( + "reused", + ProcessStatus::Running, + Some(now_ms()), + )]); + app.select_next_process(); + let mut replacement = process("reused", ProcessStatus::Completed, Some(0)); + replacement.view.incarnation = + lash::process::ProcessIncarnation::from_registration_sequence(2); + app.update_processes(vec![replacement]); + assert!(app.processes.is_empty()); + assert!(app.selected_process_ref.is_none()); + } + + #[test] + fn stale_snapshot_does_not_regress_same_incarnation() { + let mut app = App::new("model".into(), "provider".into(), "session".into()); + let mut newer = process("task", ProcessStatus::Completed, Some(now_ms())); + newer.last_event_sequence = 3; + app.update_processes(vec![newer]); + app.update_processes(vec![process( + "task", + ProcessStatus::Running, + Some(now_ms()), + )]); + assert_eq!(app.processes[0].status, ProcessStatus::Completed); + assert_eq!(app.processes[0].last_event_sequence, 3); + } + fn now_ms() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -1324,6 +1379,6 @@ mod process_retention_tests { app.on_tick(); assert!(app.processes.is_empty()); - assert!(app.selected_process_id.is_none()); + assert!(app.selected_process_ref.is_none()); } } diff --git a/crates/lash-cli/src/app/runtime_events.rs b/crates/lash-cli/src/app/runtime_events.rs index 902b3ab..148c9c0 100644 --- a/crates/lash-cli/src/app/runtime_events.rs +++ b/crates/lash-cli/src/app/runtime_events.rs @@ -511,7 +511,9 @@ impl App { // `CodeBlockStarted`. On failure, surface the error right after // it so a failed block isn't silently swallowed. Observations // are intentionally not rendered here. - if let Some(message) = error.filter(|_| !success) { + if let Some(failure) = error.filter(|_| !success) { + tracing::warn!(kind = ?failure.kind, message = %failure.message, "cell failed"); + let message = format!("{:?}: {}", failure.kind, failure.message); self.finalize_live_markdown(); let changed_idx = self.timeline.len(); let invalidate_from = self.append_invalidation_start(); diff --git a/crates/lash-cli/src/app/runtime_events_tests.rs b/crates/lash-cli/src/app/runtime_events_tests.rs index 110f2f0..eee9baf 100644 --- a/crates/lash-cli/src/app/runtime_events_tests.rs +++ b/crates/lash-cli/src/app/runtime_events_tests.rs @@ -364,3 +364,25 @@ fn plugin_runtime_maps_plan_process_status_event() { ); assert!(turn.transient_until.is_some()); } + +#[test] +fn cell_failure_renders_kind_and_message() { + let mut app = app(); + app.handle_turn_activity(activity( + "cell", + TurnEvent::CodeBlockCompleted { + language: "lashlang".into(), + output: String::new(), + error: Some(lash::plugins::CellFailure { + kind: lash::plugins::CellFailureKind::Policy, + message: "not allowed".into(), + }), + success: false, + duration_ms: 1, + tool_call_ids: Vec::new(), + graph_key: None, + }, + )); + assert!(app.timeline.iter().any(|item| matches!(item, + UiTimelineItem::Error(message) if message == "Policy: not allowed"))); +} diff --git a/crates/lash-cli/src/app/view.rs b/crates/lash-cli/src/app/view.rs index f1680f9..136e1ed 100644 --- a/crates/lash-cli/src/app/view.rs +++ b/crates/lash-cli/src/app/view.rs @@ -46,7 +46,7 @@ impl App { plugin_mode_indicators, plan_dock, processes, - selected_process_id, + selected_process_ref, activity_state, ui_activity_journal, pending_ui_activity_records, @@ -92,7 +92,7 @@ impl App { plugin_mode_indicators.clear(); *plan_dock = None; processes.clear(); - *selected_process_id = None; + *selected_process_ref = None; activity_state.reset(); *ui_activity_journal = UiActivityJournal::default(); pending_ui_activity_records.clear(); diff --git a/crates/lash-cli/src/autonomous.rs b/crates/lash-cli/src/autonomous.rs index f93b7a8..f653b4c 100644 --- a/crates/lash-cli/src/autonomous.rs +++ b/crates/lash-cli/src/autonomous.rs @@ -227,6 +227,8 @@ fn json_finish_record( "usage": turn.usage, "children_usage": turn.children_usage, "errors": turn.errors, + "failure_evidence": turn.failure_evidence, + "omitted": turn.omitted, "execution": turn.execution, "tool_calls": tool_calls, }); @@ -313,6 +315,9 @@ impl AutonomousOutput { fn finish_success(&mut self, turn: &lash::TurnReport, cancelled: bool) -> anyhow::Result<()> { match self { AutonomousOutput::Print(renderer) => { + for line in crate::util::turn_diagnostic_lines(turn) { + eprintln!("{line}"); + } if !turn.assistant_output.safe_text.is_empty() { renderer.finish_output(&turn.assistant_output.safe_text); } else if let Some(rendered) = renderer.rendered_plugin_output() { @@ -339,8 +344,8 @@ impl AutonomousOutput { fn finish_failure(&mut self, turn: &lash::TurnReport, cancelled: bool) -> anyhow::Result<()> { match self { AutonomousOutput::Print(_) => { - for issue in &turn.errors { - eprintln!("error: {}", issue.message); + for line in crate::util::turn_diagnostic_lines(turn) { + eprintln!("{line}"); } if turn.errors.is_empty() { eprintln!("error: autonomous turn failed"); @@ -683,6 +688,8 @@ async fn run_rpc( "outcome": done.result.outcome, "usage": done.result.usage, "errors": done.result.errors, + "failure_evidence": done.result.failure_evidence, + "omitted": done.result.omitted, }, }))?; } @@ -757,11 +764,52 @@ mod tests { duration_ms: 42, }, errors: Vec::new(), + failure_evidence: Vec::new(), + omitted: None, acceptance: None, cancel_input_outcome: Default::default(), } } + #[test] + fn advisory_and_failure_evidence_diagnostics_preserve_meaning() { + let mut turn = sample_turn_result(); + turn.errors.push( + serde_json::from_value(json!({ + "severity": "advisory", "kind": "attachment", "message": "Attachment omitted" + })) + .unwrap(), + ); + assert_eq!( + crate::util::turn_diagnostic_lines(&turn), + ["warning: Attachment omitted"] + ); + assert_eq!(json_finish_record(1, None, &turn, false)["ok"], true); + turn.outcome = TurnOutcome::Stopped(TurnStop::RuntimeError); + turn.failure_evidence.push( + serde_json::from_value(json!({ + "partial_output": {"residency": "complete", "text": "PRIVATE PARTIAL"}, + "billed_usage": {"input_tokens": 0, "output_tokens": 0, + "cache_read_input_tokens": 0, "cache_write_input_tokens": 0, + "reasoning_output_tokens": 0}, + "refusal": {"code": "charge_safety_guarantee_required", + "denial_reason": "guarantee_required", "protocol_position": "output_started", + "attempt_number": 1, "attempt_count": 1} + })) + .unwrap(), + ); + let lines = crate::util::turn_diagnostic_lines(&turn).join("\n"); + assert!(lines.contains("charge_safety_guarantee_required")); + assert!(lines.contains("defaulted")); + assert!(lines.contains("partial output present")); + assert!(!lines.contains("PRIVATE PARTIAL")); + turn.failure_evidence[0].billed_usage.input_tokens = 7; + turn.failure_evidence[0].billed_usage.output_tokens = 3; + turn.failure_evidence[0].partial_output = None; + let lines = crate::util::turn_diagnostic_lines(&turn).join("\n"); + assert!(lines.contains("billed tokens 10; partial output absent")); + } + #[test] fn turn_start_record_stamps_protocol_version() { assert_eq!( diff --git a/crates/lash-cli/src/capability_catalog.rs b/crates/lash-cli/src/capability_catalog.rs index bfc8423..c1a610f 100644 --- a/crates/lash-cli/src/capability_catalog.rs +++ b/crates/lash-cli/src/capability_catalog.rs @@ -116,6 +116,7 @@ impl Row { cache_control: self.cache_control, stream_termination: None, sampling: Default::default(), + ..Default::default() } } } @@ -344,25 +345,168 @@ fn builtin_capability_override(_kind: &str, _model: &str) -> Option ModelCapability { if let Some(override_capability) = builtin_capability_override(kind, model) { return override_capability; } let full = model.to_lowercase(); let bare = full.rsplit('/').next().unwrap_or(&full); - ROWS.iter() + let mut capability = ROWS + .iter() .find(|row| row.kind == kind && row.rule.matches(&full, bare)) .map(Row::to_capability) - .unwrap_or_default() + .unwrap_or_default(); + let known_attachment_model = match kind { + "openai" | "codex" => bare.starts_with("gpt-"), + "anthropic" => bare.starts_with("claude-"), + "google_oauth" => bare.starts_with("gemini-"), + "openai-compatible" => ["gpt-", "claude-", "gemini-"] + .iter() + .any(|prefix| bare.starts_with(prefix)), + _ => false, + }; + if capability.reasoning.is_some() || known_attachment_model { + capability.attachment_acceptance = attachment_capability(); + } + if kind == "google_oauth" { + capability.google_dialect = if bare.starts_with("gemini-3") { + lash::provider::GoogleDialect::Gemini3 + } else if bare.starts_with("claude") { + lash::provider::GoogleDialect::ClaudeOnVertex + } else { + lash::provider::GoogleDialect::Legacy + }; + } + capability +} + +/// Host-owned snapshot of the adapter formats at the pinned runtime revision. +/// Admission and diagnostics both consume this snapshot; there is no separate +/// UI acceptance table. Keep the prior adapter rules when upgrading the pin. +fn attachment_capability() -> std::sync::Arc { + use lash::provider::{ + AttachmentAcceptanceRule as Rule, AttachmentAcceptor, AttachmentCapabilitySnapshot, + AttachmentMimeSource as Source, + }; + static SNAPSHOT: std::sync::OnceLock> = + std::sync::OnceLock::new(); + SNAPSHOT + .get_or_init(|| { + let images = ["image/jpeg", "image/png", "image/gif", "image/webp"]; + let responses = [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "application/pdf", + "application/json", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "text/csv", + "text/html", + "text/markdown", + "text/plain", + ]; + let mut acceptors = Vec::new(); + for (provider, types, families, urls, file_providers) in [ + ( + "OpenAI Responses", + responses.as_slice(), + &[][..], + true, + &["openai"][..], + ), + ( + "OpenAI Chat Completions", + images.as_slice(), + &[][..], + true, + &[][..], + ), + ( + "Anthropic Messages", + &[ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "application/pdf", + ][..], + &[][..], + true, + &["anthropic"][..], + ), + ( + "Google Gemini", + &[ + "image/jpeg", + "image/png", + "image/webp", + "image/heic", + "image/heif", + "application/pdf", + ][..], + &["audio", "text", "video"][..], + false, + &["google", "google_oauth", "gemini"][..], + ), + ] { + let mut sources = vec![Source::Inline, Source::Stored]; + if urls { + sources.push(Source::ExternalUrl); + } + let mut rules: Vec<_> = sources + .into_iter() + .map(|source| Rule::Mime { + source, + media_types: types.iter().map(|s| (*s).to_string()).collect(), + media_families: families.iter().map(|s| (*s).to_string()).collect(), + }) + .collect(); + rules.extend(file_providers.iter().map(|provider| Rule::ProviderFile { + provider: (*provider).to_string(), + })); + acceptors.push(AttachmentAcceptor { + provider: provider.to_string(), + rules, + }); + } + AttachmentCapabilitySnapshot { + revision: "lash-cli-46dfed8-attachments-1".into(), + acceptors, + } + .into() + }) + .clone() } #[cfg(test)] mod tests { use super::*; + #[test] + fn attachment_snapshot_retains_adapter_formats_and_google_dialect() { + let cap = capability_for("openai", "gpt-5.4"); + let snapshot = &cap.attachment_acceptance; + let source: lash::direct::AttachmentSource = serde_json::from_value(serde_json::json!({ + "source": "inline", "media_type": "image/png", "bytes": [1, 2, 3] + })) + .unwrap(); + assert!(snapshot.accepts("OpenAI Responses", &source)); + assert!(snapshot.accepts("OpenAI Chat Completions", &source)); + assert_eq!( + capability_for("google_oauth", "gemini-3.1-pro-preview").google_dialect, + lash::provider::GoogleDialect::Gemini3 + ); + } + fn efforts(cap: &ModelCapability) -> Vec { cap.reasoning .as_ref() diff --git a/crates/lash-cli/src/interactive/commands/mod.rs b/crates/lash-cli/src/interactive/commands/mod.rs index 464daf8..5834db8 100644 --- a/crates/lash-cli/src/interactive/commands/mod.rs +++ b/crates/lash-cli/src/interactive/commands/mod.rs @@ -237,7 +237,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); + let rlm_dialect = session::current_rlm_dialect(runtime)?; session::handle_clear( app, runtime_factory, @@ -273,10 +273,7 @@ pub(super) async fn handle_builtin_command( let context_window = app.usage.context_window; let cwd = app.cwd.clone(); let session_name = app.session_name.clone(); - let rlm_dialect = runtime.as_ref().and_then(|session| { - use lash::rlm::RlmSessionExt as _; - session.rlm_config().dialect - }); + let rlm_dialect = session::current_rlm_dialect(runtime)?; let session_db_path = logger.db_path().to_string_lossy().to_string(); app.show_document(info_document( provider, diff --git a/crates/lash-cli/src/interactive/commands/session.rs b/crates/lash-cli/src/interactive/commands/session.rs index a0259af..2c2ed3d 100644 --- a/crates/lash-cli/src/interactive/commands/session.rs +++ b/crates/lash-cli/src/interactive/commands/session.rs @@ -53,7 +53,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)); + app.set_rlm_dialect(current_rlm_dialect(runtime).map_err(|err| err.to_string())?); crate::startup::session::refresh_tool_catalog_and_wait( session, "interactive session open", @@ -97,11 +97,14 @@ fn fallback_policy_for_session_switch( pub(super) fn current_rlm_dialect( runtime: &Option, -) -> Option { +) -> anyhow::Result> { use lash::rlm::RlmSessionExt as _; runtime .as_ref() - .and_then(|session| session.rlm_config().dialect) + .map(|session| session.rlm_config().map(|config| config.dialect)) + .transpose() + .map(Option::flatten) + .map_err(Into::into) } #[allow(clippy::too_many_arguments)] @@ -142,7 +145,7 @@ pub(super) async fn handle_clear( let Some(session) = runtime.as_ref() else { return Err(anyhow::anyhow!("opened session was not installed")); }; - app.set_rlm_dialect(current_rlm_dialect(runtime)); + app.set_rlm_dialect(current_rlm_dialect(runtime)?); if let Some(rt) = runtime.as_ref() { crate::startup::session::refresh_tool_catalog_and_wait( rt, @@ -335,7 +338,7 @@ pub(crate) async fn switch_to_session_identifier( identifier, policy, *current_execution_mode, - current_rlm_dialect(runtime), + current_rlm_dialect(runtime)?, ) .await?; activate_opened_session( diff --git a/crates/lash-cli/src/interactive/mod.rs b/crates/lash-cli/src/interactive/mod.rs index bbaca0f..8af9e4b 100644 --- a/crates/lash-cli/src/interactive/mod.rs +++ b/crates/lash-cli/src/interactive/mod.rs @@ -525,6 +525,9 @@ pub(crate) async fn run_app( } app.finish_turn_from_read_view(&read_view); + for line in crate::util::turn_diagnostic_lines(&done.result) { + push_system_message(&mut app, line); + } app.clear_manual_interrupt_requested(); runtime_return_rx = None; cancel_token = None; diff --git a/crates/lash-cli/src/mcp_discovery/rerank.rs b/crates/lash-cli/src/mcp_discovery/rerank.rs index 0e6af0f..d4d2a10 100644 --- a/crates/lash-cli/src/mcp_discovery/rerank.rs +++ b/crates/lash-cli/src/mcp_discovery/rerank.rs @@ -148,7 +148,6 @@ pub(crate) fn llm_rerank_request( parts: vec![DirectPart::Text(prompt)], }, ], - attachments: Vec::new(), output: DirectOutputSpec::JsonSchema(schema), stream_events: None, generation: lash::direct::GenerationOptions::default(), diff --git a/crates/lash-cli/src/render/compositor/tests.rs b/crates/lash-cli/src/render/compositor/tests.rs index 2e59713..fd0865f 100644 --- a/crates/lash-cli/src/render/compositor/tests.rs +++ b/crates/lash-cli/src/render/compositor/tests.rs @@ -384,9 +384,11 @@ mod tests { app.update_processes(vec![crate::app::ProcessSnapshot { view: lash::process::ProcessHandleView::new( "process-1", + lash::process::ProcessIncarnation::from_registration_sequence(1), lash::process::ProcessIdentity::new("lashlang").with_label(Some("responder")), lash::process::ProcessStatus::Running, ), + last_event_sequence: 1, updated_at_ms: None, }]); sync_chrome_turn_status(&app); @@ -427,6 +429,7 @@ mod tests { app.update_processes(vec![crate::app::ProcessSnapshot { view: lash::process::ProcessHandleView::new( "process-1", + lash::process::ProcessIncarnation::from_registration_sequence(1), lash::process::ProcessIdentity::new("lashlang") .with_label(Some("responder")) .with_definition(Some( @@ -440,6 +443,7 @@ mod tests { )), lash::process::ProcessStatus::Running, ), + last_event_sequence: 1, updated_at_ms: None, }]); app.select_next_process(); diff --git a/crates/lash-cli/src/render/sections/docks.rs b/crates/lash-cli/src/render/sections/docks.rs index 49d8648..6f134b2 100644 --- a/crates/lash-cli/src/render/sections/docks.rs +++ b/crates/lash-cli/src/render/sections/docks.rs @@ -74,7 +74,7 @@ pub fn process_lines_snapshot(app: &App, _frame_width: u16) -> Option "success", lash::process::ProcessStatus::Failed => "error", diff --git a/crates/lash-cli/src/startup/plugins.rs b/crates/lash-cli/src/startup/plugins.rs index 6f2c6dc..6a407a9 100644 --- a/crates/lash-cli/src/startup/plugins.rs +++ b/crates/lash-cli/src/startup/plugins.rs @@ -241,8 +241,8 @@ mod tests { let template = layer.template.as_ref().expect("cli prompt template"); let contributions = layer .slots - .values() - .flat_map(|slot| slot.contributions.iter()) + .iter() + .flat_map(|(key, slot)| slot.contributions.iter().map(move |body| (key, body))) .collect::>(); let execution = template @@ -259,8 +259,8 @@ mod tests { ) })); assert!( - !contributions.iter().any(|contribution| { - contribution.slot == PromptSlot::Execution + !contributions.iter().any(|(slot, contribution)| { + **slot == PromptSlot::Execution && contribution.title.as_deref() == Some("RLM Response Finalization") }), "RLM response-shape guidance belongs to the protocol execution section" @@ -272,11 +272,11 @@ mod tests { let layer = cli_prompt_config(false, &ExecutionMode::Standard); let contributions = layer .slots - .values() - .flat_map(|slot| slot.contributions.iter()) + .iter() + .flat_map(|(key, slot)| slot.contributions.iter().map(move |body| (key, body))) .collect::>(); - assert!(!contributions.iter().any(|contribution| { + assert!(!contributions.iter().any(|(_, contribution)| { contribution.title.as_deref() == Some("RLM Response Finalization") })); } @@ -287,8 +287,8 @@ mod tests { let template = layer.template.as_ref().expect("cli prompt template"); let contributions = layer .slots - .values() - .flat_map(|slot| slot.contributions.iter()) + .iter() + .flat_map(|(key, slot)| slot.contributions.iter().map(move |body| (key, body))) .collect::>(); assert!(template.sections.iter().any(|section| { @@ -311,12 +311,11 @@ mod tests { ) }) })); - assert!(contributions.iter().any(|contribution| { - contribution.slot == PromptSlot::Intro - && contribution.content.as_ref() == CLI_AUTONOMOUS_INTRO + assert!(contributions.iter().any(|(slot, contribution)| { + **slot == PromptSlot::Intro && contribution.content.as_ref() == CLI_AUTONOMOUS_INTRO })); - assert!(contributions.iter().any(|contribution| { - contribution.slot == PromptSlot::Execution + assert!(contributions.iter().any(|(slot, contribution)| { + **slot == PromptSlot::Execution && contribution.content.as_ref() == CLI_AUTONOMOUS_EXECUTION })); } diff --git a/crates/lash-cli/src/startup/session.rs b/crates/lash-cli/src/startup/session.rs index 605c9d7..4953c8a 100644 --- a/crates/lash-cli/src/startup/session.rs +++ b/crates/lash-cli/src/startup/session.rs @@ -348,7 +348,7 @@ impl CliSessionOpener { 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(); + let recorded = session.rlm_config()?.dialect.unwrap_or_default(); if recorded != requested { anyhow::bail!( "RLM dialect conflict: session records `{}`, requested `{}`", diff --git a/crates/lash-cli/src/turn_runner.rs b/crates/lash-cli/src/turn_runner.rs index 9e91f9a..e3c8516 100644 --- a/crates/lash-cli/src/turn_runner.rs +++ b/crates/lash-cli/src/turn_runner.rs @@ -104,10 +104,9 @@ pub(crate) fn spawn_session_turn( 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}"); + // Stream IDs restart on CLI launch; let Lash mint durable turn identity. task_session .turn(turn_input) - .turn_id(turn_id) .cancel(task_cancel) .stream_to(&target_sink) .await @@ -146,10 +145,10 @@ pub(crate) fn spawn_session_queued_turn( 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}"); task_session .queued_turn() - .drain_id(drain_id) + // Keep the host-owned drain label without reusing a stream counter. + .drain_id(format!("cli-queue-drain:{}", uuid::Uuid::new_v4())) .cancel(task_cancel) .stream_to(&target_sink) .await @@ -229,8 +228,11 @@ async fn runtime_error_turn_result(session: &LashSession, message: String) -> la usage: TokenUsage::default(), children_usage: Vec::new(), llm_calls: Vec::new(), + failure_evidence: Vec::new(), + omitted: None, tool_calls: Vec::new(), errors: vec![TurnIssue { + severity: lash::turn::TurnIssueSeverity::Blocking, kind: "runtime".to_string(), code: Some(message.clone()), terminal_reason: None, @@ -322,11 +324,11 @@ mod tests { lash::persistence::QueuedWorkBatchDraft::new( session_id, lash::persistence::DeliveryPolicy::EarliestSafeBoundary, - vec![lash::persistence::QueuedWorkPayload::agent_frame_task( + lash::persistence::TurnWorkPayload::agent_frame_task( lash::testing::frame_node_id(session_id, source_key), task, None, - )], + ), ) .with_source_key(source_key), ) @@ -334,6 +336,59 @@ mod tests { .expect("enqueue selected queued work") } + #[tokio::test] + async fn catalog_refresh_leaves_pending_input_and_unrelated_work_untouched() { + let provider = lash::testing::TestProvider::builder() + .complete(|_| async { panic!("catalog refresh must never call the model") }) + .build() + .into_handle(); + let (core, store_factory) = queued_cancel_test_core(provider); + let session = core.session("catalog-refresh").open().await.unwrap(); + let pending = session + .enqueue(TurnInput::text("pending user prompt")) + .id("pending-user") + .send() + .await + .unwrap(); + crate::startup::session::refresh_tool_catalog_and_wait(&session, "test", "catalog-only") + .await + .unwrap(); + assert!(session.queued_work().await.unwrap().is_empty()); + assert_eq!( + session.pending_turn_inputs().await.unwrap()[0].input_id, + pending.input_id + ); + let unrelated = enqueue_selected_task( + &store_factory, + &session.session_id(), + "unrelated-work", + "do not execute".into(), + ) + .await; + let error = crate::startup::session::refresh_tool_catalog_and_wait( + &session, + "test", + "blocked-catalog", + ) + .await + .expect_err("unclaimable selection must fail closed"); + assert!(matches!( + error.downcast_ref::(), + Some(lash::EmbedError::SelectedQueuedWorkDrainRefused { .. }) + )); + let remaining = session.queued_work().await.unwrap(); + assert_eq!(remaining.len(), 2); + assert!( + remaining + .iter() + .any(|batch| batch.batch_id == unrelated.batch_id) + ); + let remaining_inputs = session.pending_turn_inputs().await.unwrap(); + assert_eq!(remaining_inputs.len(), 1); + assert_eq!(remaining_inputs[0].input_id, pending.input_id); + assert_eq!(session.read_view().turn_index(), 0); + } + #[tokio::test] async fn direct_turn_started_immediately_targets_exact_cancellation() { let provider = lash::testing::TestProvider::builder() @@ -359,7 +414,6 @@ mod tests { ); let turn_id = observed_turn_target(&mut event_pump, 5).await; - assert_eq!(turn_id, "cli-turn:5"); let receipt = session .request_turn_cancel( &turn_id, diff --git a/crates/lash-cli/src/ui_effects.rs b/crates/lash-cli/src/ui_effects.rs index bd8e1a9..53eb5ee 100644 --- a/crates/lash-cli/src/ui_effects.rs +++ b/crates/lash-cli/src/ui_effects.rs @@ -222,6 +222,8 @@ pub(crate) fn observed_to_process_snapshot( ) -> crate::app::ProcessSnapshot { let lash::process::ObservedProcess { process_id, + incarnation, + last_event_sequence, graph_key: _, kind: _, lifecycle, @@ -246,8 +248,9 @@ pub(crate) fn observed_to_process_snapshot( label: _, } = process; crate::app::ProcessSnapshot { - view: lash::process::ProcessHandleView::new(process_id, identity, lifecycle), + view: lash::process::ProcessHandleView::new(process_id, incarnation, identity, lifecycle), updated_at_ms: Some(updated_at_ms), + last_event_sequence, } } @@ -262,7 +265,7 @@ pub(crate) async fn collect_ui_snapshot( ) -> crate::event::UiSnapshotResult { let started = std::time::Instant::now(); let mut diagnostics = Vec::new(); - let processes = match session.processes().list_all().await { + let processes = match session.admin().processes().list_all().await { Ok(tasks) => Some( tasks .into_iter() diff --git a/crates/lash-cli/src/util.rs b/crates/lash-cli/src/util.rs index 2da3319..21f4c3a 100644 --- a/crates/lash-cli/src/util.rs +++ b/crates/lash-cli/src/util.rs @@ -66,6 +66,59 @@ pub fn is_cancelled_error(message: &str, code: Option<&str>) -> bool { ) } +/// Compact settlement diagnostics shared by terminal modes. Evidence usage has +/// no presence marker upstream: a zero value may be reported or defaulted. +pub(crate) fn turn_diagnostic_lines(turn: &lash::TurnReport) -> Vec { + let mut lines: Vec<_> = turn + .errors + .iter() + .map(|issue| { + let label = match issue.severity { + lash::turn::TurnIssueSeverity::Advisory => "warning", + lash::turn::TurnIssueSeverity::Blocking => "error", + }; + format!("{label}: {}", issue.message) + }) + .collect(); + if matches!(turn.outcome, lash::TurnOutcome::Stopped(_)) { + for evidence in &turn.failure_evidence { + let usage = &evidence.billed_usage; + let tokens = [ + usage.input_tokens, + usage.output_tokens, + usage.cache_read_input_tokens, + usage.cache_write_input_tokens, + ] + .into_iter() + .fold(0u64, |sum, value| sum.saturating_add(value.max(0) as u64)); + let usage_label = if tokens == 0 { + "0 (reported or defaulted; usage may be unavailable)".to_string() + } else { + tokens.to_string() + }; + lines.push(format!( + "{}: billed tokens {}; partial output {}", + evidence.refusal.code, + usage_label, + if evidence.partial_output.is_some() { + "present" + } else { + "absent" + } + )); + } + } + if let Some(omitted) = &turn.omitted { + lines.push(format!( + "Tool records omitted: {} ({} failures, {} attachments)", + omitted.count, + omitted.failures, + omitted.attachments.len() + )); + } + lines +} + #[cfg(test)] mod tests { use super::{format_duration_ms, format_duration_ms_if_visible}; diff --git a/crates/lash-export/src/lib.rs b/crates/lash-export/src/lib.rs index 637d252..829ea41 100644 --- a/crates/lash-export/src/lib.rs +++ b/crates/lash-export/src/lib.rs @@ -297,7 +297,14 @@ mod tests { use super::*; - fn runtime_builder(store_root: &Path, trace_path: &Path) -> lash::LashCoreBuilder { + async fn runtime_builder(store_root: &Path, trace_path: &Path) -> lash::LashCoreBuilder { + std::fs::create_dir_all(store_root).expect("store root"); + let registry_path = store_root.join("processes.db"); + let registry = Arc::new( + lash_sqlite_store::SqliteProcessRegistry::open(®istry_path, store_root) + .await + .expect("process registry"), + ); let provider = lash::testing::TestProvider::builder() .kind("lash-export-fixture") .complete(|_request| async move { @@ -321,7 +328,10 @@ mod tests { .build() .expect("valid fixture model"), ) - .store_factory(Arc::new(SqliteSessionStoreFactory::new(store_root))) + .store_factory(Arc::new( + SqliteSessionStoreFactory::new_with_process_registry(store_root, registry_path), + )) + .process_registry(registry) .effect_host(Arc::new(lash::durability::NativeEffectHost::default())) .attachment_store(Arc::new(lash::persistence::InMemoryAttachmentStore::new())) .process_env_store(Arc::new( @@ -339,6 +349,7 @@ mod tests { let store_root = temp.path().join("sessions"); let trace_path = temp.path().join("trace.jsonl"); let core = runtime_builder(&store_root, &trace_path) + .await .build(lash::persistence::LeaseOwnerIdentity::opaque( "lash-export-test", "lash-export-test:boot", diff --git a/crates/lash-export/src/trace.rs b/crates/lash-export/src/trace.rs index 1f4f8da..586b30f 100644 --- a/crates/lash-export/src/trace.rs +++ b/crates/lash-export/src/trace.rs @@ -297,7 +297,6 @@ mod tests { model: "gpt-5.5".to_string(), model_variant: Some("high".to_string()), messages, - attachments: Vec::new(), tools: Vec::new(), tool_choice: "auto".to_string(), output_spec: None, @@ -345,6 +344,7 @@ mod tests { }, TraceEvent::LlmCallCompleted { response: TraceLlmResponse { + request_model: "gpt-5.5".to_string(), text: "ok".to_string(), duration_ms: 1234, terminal_reason: None, @@ -471,6 +471,7 @@ mod tests { }, TraceEvent::LlmCallCompleted { response: TraceLlmResponse { + request_model: "gpt-5.5".to_string(), text: "ok".to_string(), duration_ms: 1, terminal_reason: None, From 4fcc4aa96893e27bb9e95ef4571e4f36063770c4 Mon Sep 17 00:00:00 2001 From: Samuel Galanakis Date: Tue, 8 Sep 2026 10:48:50 +0200 Subject: [PATCH 2/7] Bound dock observations to the terminal retention window Use the same session observer scope for periodic snapshots and post-cancel refreshes. Keep all live rows and limit retired results to PROCESS_RETENTION. The upstream registry still scans observer records before filtering in memory; this bounds returned history and benefits from batched lease reads. Mitigates #15 Release-Notes: Reduce process dock snapshot work while preserving its ten-second retention and observer visibility. --- crates/lash-cli/src/app/mod.rs | 2 +- crates/lash-cli/src/interactive/helpers.rs | 7 ++- .../src/interactive/input_handling/modals.rs | 10 ++-- .../src/interactive/input_handling/turns.rs | 2 +- crates/lash-cli/src/interactive/mod.rs | 2 +- crates/lash-cli/src/startup/session.rs | 18 +++++++ crates/lash-cli/src/ui_effects.rs | 52 ++++++++++++++++++- 7 files changed, 84 insertions(+), 9 deletions(-) diff --git a/crates/lash-cli/src/app/mod.rs b/crates/lash-cli/src/app/mod.rs index 9679054..9fbc266 100644 --- a/crates/lash-cli/src/app/mod.rs +++ b/crates/lash-cli/src/app/mod.rs @@ -43,7 +43,7 @@ pub(crate) use self::projection::{ }; pub(crate) use self::queues::turn_input_display_text; -const PROCESS_RETENTION: std::time::Duration = std::time::Duration::from_secs(10); +pub(crate) const PROCESS_RETENTION: std::time::Duration = std::time::Duration::from_secs(10); pub(crate) struct ProcessSnapshot { pub view: ProcessHandleView, diff --git a/crates/lash-cli/src/interactive/helpers.rs b/crates/lash-cli/src/interactive/helpers.rs index 3d2715c..63585bf 100644 --- a/crates/lash-cli/src/interactive/helpers.rs +++ b/crates/lash-cli/src/interactive/helpers.rs @@ -216,14 +216,17 @@ struct UiSnapshotRequest { } impl UiSnapshotWorker { - pub(super) fn spawn(app_tx: AppEventTx) -> Self { + pub(super) fn spawn( + app_tx: AppEventTx, + runtime_factory: crate::startup::session::CliSessionOpener, + ) -> Self { let (request_tx, mut request_rx) = mpsc::unbounded_channel::(); tokio::spawn(async move { while let Some(request) = request_rx.recv().await { let started = std::time::Instant::now(); let snapshot = tokio::time::timeout( std::time::Duration::from_millis(200), - collect_ui_snapshot(request.session.clone()), + collect_ui_snapshot(request.session.clone(), &runtime_factory), ) .await; let result = match snapshot { diff --git a/crates/lash-cli/src/interactive/input_handling/modals.rs b/crates/lash-cli/src/interactive/input_handling/modals.rs index a51f963..1a46bda 100644 --- a/crates/lash-cli/src/interactive/input_handling/modals.rs +++ b/crates/lash-cli/src/interactive/input_handling/modals.rs @@ -118,7 +118,11 @@ pub(super) fn show_selected_process_overview(app: &mut App) { app.show_process_overview(overview); } -pub(super) async fn cancel_selected_process(app: &mut App, runtime: &Option) { +pub(super) async fn cancel_selected_process( + app: &mut App, + runtime: &Option, + runtime_factory: &crate::startup::session::CliSessionOpener, +) { let Some(process) = app.selected_process().cloned() else { return; }; @@ -163,7 +167,7 @@ pub(super) async fn cancel_selected_process(app: &mut App, runtime: &Option app.update_processes( processes .into_iter() @@ -357,7 +361,7 @@ pub(super) async fn handle_process_overview_key(key: KeyEvent, ctx: &mut Session match key.code { KeyCode::Enter => ctx.app.dismiss_process_overview(), KeyCode::Delete => { - cancel_selected_process(ctx.app, ctx.runtime).await; + cancel_selected_process(ctx.app, ctx.runtime, ctx.runtime_factory).await; ctx.app.dismiss_process_overview(); } _ => {} diff --git a/crates/lash-cli/src/interactive/input_handling/turns.rs b/crates/lash-cli/src/interactive/input_handling/turns.rs index 6c3d3a5..435f25b 100644 --- a/crates/lash-cli/src/interactive/input_handling/turns.rs +++ b/crates/lash-cli/src/interactive/input_handling/turns.rs @@ -225,7 +225,7 @@ pub(super) async fn handle_input_mode_key( show_selected_process_overview(ctx.app); } KeyCode::Delete if process_dock_has_focus(ctx.app) => { - cancel_selected_process(ctx.app, ctx.runtime).await; + cancel_selected_process(ctx.app, ctx.runtime, ctx.runtime_factory).await; } KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { ctx.app.editor.kill_to_line_start(); diff --git a/crates/lash-cli/src/interactive/mod.rs b/crates/lash-cli/src/interactive/mod.rs index 8af9e4b..5490279 100644 --- a/crates/lash-cli/src/interactive/mod.rs +++ b/crates/lash-cli/src/interactive/mod.rs @@ -149,7 +149,7 @@ pub(crate) async fn run_app( let mut event_pump = AppEventPump::new(); let app_tx = event_pump.sender(); prompt_bridge.set_event_tx(app_tx.clone()); - let mut snapshot_worker = UiSnapshotWorker::spawn(app_tx.clone()); + let mut snapshot_worker = UiSnapshotWorker::spawn(app_tx.clone(), runtime_factory.clone()); // Kick off the background `@`-completion file index. Starting it here // (rather than lazily on the first `@` keystroke) means the walk is diff --git a/crates/lash-cli/src/startup/session.rs b/crates/lash-cli/src/startup/session.rs index 4953c8a..9b59bd6 100644 --- a/crates/lash-cli/src/startup/session.rs +++ b/crates/lash-cli/src/startup/session.rs @@ -195,6 +195,24 @@ impl CliSessionOpener { .ok_or_else(|| anyhow::anyhow!("no Lash core is open")) } + pub(crate) async fn list_dock_processes( + &self, + session: &LashSession, + ) -> Result> { + let core = self.active_core().await?; + let session_scope = session.observe().process_scope(); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_millis() as u64); + Ok(core + .processes() + .list_observed_by( + &session_scope, + &crate::ui_effects::process_dock_filter(now_ms), + ) + .await?) + } + pub(crate) async fn list_recent_sessions( &self, limit: usize, diff --git a/crates/lash-cli/src/ui_effects.rs b/crates/lash-cli/src/ui_effects.rs index 53eb5ee..0c0d6b8 100644 --- a/crates/lash-cli/src/ui_effects.rs +++ b/crates/lash-cli/src/ui_effects.rs @@ -260,12 +260,23 @@ fn push_plugin_operation_message(app: &mut App, output: &serde_json::Value) { } } +pub(crate) fn process_dock_filter(now_ms: u64) -> lash::process::ProcessListFilter { + lash::process::ProcessListFilter { + status: lash::process::ProcessStatusFilter::Any, + retired_since_ms: Some( + now_ms.saturating_sub(crate::app::PROCESS_RETENTION.as_millis() as u64), + ), + ..Default::default() + } +} + pub(crate) async fn collect_ui_snapshot( session: lash::LashSession, + runtime_factory: &crate::startup::session::CliSessionOpener, ) -> crate::event::UiSnapshotResult { let started = std::time::Instant::now(); let mut diagnostics = Vec::new(); - let processes = match session.admin().processes().list_all().await { + let processes = match runtime_factory.list_dock_processes(&session).await { Ok(tasks) => Some( tasks .into_iter() @@ -290,6 +301,45 @@ pub(crate) async fn collect_ui_snapshot( mod tests { use super::*; + #[test] + fn dock_filter_keeps_old_live_and_recent_retired_records() { + use lash::process::{ + ProcessIncarnation, ProcessInput, ProcessProvenance, ProcessRecord, + ProcessRegistration, ProcessStatus, RecoveryContract, + }; + let retention_ms = crate::app::PROCESS_RETENTION.as_millis() as u64; + let now_ms = retention_ms * 3; + let filter = process_dock_filter(now_ms); + let mut record = ProcessRecord::from_registration( + ProcessRegistration::new( + "task", + ProcessInput::External { + metadata: serde_json::Value::Null, + }, + RecoveryContract::ExternallyOwned, + ProcessProvenance::host(), + ), + ProcessIncarnation::from_registration_sequence(1), + ); + record.updated_at_ms = 0; + for status in [ProcessStatus::Running, ProcessStatus::Waiting] { + record.status = status; + assert!(filter.matches_record(&record)); + } + for status in [ + ProcessStatus::Completed, + ProcessStatus::Failed, + ProcessStatus::Cancelled, + ProcessStatus::Abandoned, + ] { + record.status = status; + record.updated_at_ms = now_ms - retention_ms; + assert!(filter.matches_record(&record)); + record.updated_at_ms -= 1; + assert!(!filter.matches_record(&record)); + } + } + #[test] fn desktop_notification_effect_respects_focus() { let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); From 4c534b6bf5f755f1b4b1ab2e150b5da6ab4b4242 Mon Sep 17 00:00:00 2001 From: Samuel Galanakis Date: Tue, 8 Sep 2026 10:50:12 +0200 Subject: [PATCH 3/7] Expose bounded charge safety as live session configuration Default to require_guarantee and allow explicit bounded duplicate billing in config.json. Apply the host policy to every shared opener invocation and to the core defaults used for native queued work and child sessions; durable policy serialization cannot preserve this choice. Release-Notes: Configure charge_safety for fresh, cleared, resumed, interactive, and autonomous sessions. --- crates/lash-cli/src/config.rs | 73 ++++++++++++++++++++++++++ crates/lash-cli/src/startup/mod.rs | 2 + crates/lash-cli/src/startup/session.rs | 5 ++ 3 files changed, 80 insertions(+) diff --git a/crates/lash-cli/src/config.rs b/crates/lash-cli/src/config.rs index 0ca8959..21f45b6 100644 --- a/crates/lash-cli/src/config.rs +++ b/crates/lash-cli/src/config.rs @@ -170,6 +170,9 @@ impl ConfigLoadOutcome { #[serde(deny_unknown_fields)] pub struct LashConfig { pub active_provider: String, + /// Live host retry policy, reapplied whenever a session is opened. + #[serde(default)] + pub charge_safety: lash::ChargeSafetyPolicy, #[serde(default)] pub theme: ThemeName, pub providers: BTreeMap, @@ -202,6 +205,7 @@ impl LashConfig { providers.insert(kind.clone(), provider); Self { active_provider: kind, + charge_safety: lash::ChargeSafetyPolicy::default(), theme: ThemeName::default(), providers, execution_mode: crate::execution_settings::ExecutionMode::default(), @@ -546,6 +550,7 @@ fn materialize_test_provider(config: &ProviderConfig) -> Result Ok(charge_safety_provider().into_handle()), "standard-echo" => Ok(standard_echo_provider().into_handle()), "standard-slow-echo" => Ok(standard_slow_echo_provider().into_handle()), "standard-gated-escape" => Ok(standard_gated_escape_provider().into_handle()), @@ -557,6 +562,48 @@ fn materialize_test_provider(config: &ProviderConfig) -> Result lash::testing::TestProvider { + let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut options = ProviderOptions::default(); + options.reliability.retry.base_delay_ms = 0; + options.reliability.retry.max_delay_ms = 0; + options.reliability.retry.jitter_ms = 0; + lash::testing::TestProvider::builder() + .kind("test") + .options(options) + .serialize_config(|| serde_json::json!({"scenario": "charge-safety"})) + .complete(move |_| { + let attempt = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async move { + let response = lash::provider::LlmResponse { + parts: vec![lash::direct::LlmOutputPart::Text { + text: if attempt == 0 { + "PRIVATE PARTIAL" + } else { + "charge-safe retry completed" + } + .into(), + response_meta: None, + }], + ..Default::default() + }; + if attempt == 0 { + Err(lash::provider::LlmTransportError::new("stream interrupted") + .with_kind(lash::provider::ProviderFailureKind::Stream) + .with_retry_verdict( + lash::provider::TransportRetryVerdict::RetryableTransient, + ) + .with_output_started(true) + .with_partial_response(response)) + } else { + Ok(response) + } + } + }) + .build() +} + #[cfg(feature = "test-provider")] fn rlm_typescript_smoke_provider() -> lash::testing::TestProvider { lash::testing::TestProvider::builder() @@ -859,6 +906,32 @@ fn request_contains_subagent_prompt(request: &lash::provider::LlmRequest) -> boo mod tests { use super::*; + #[test] + fn charge_safety_defaults_and_roundtrips_bounded_opt_in() { + let mut value = serde_json::json!({"active_provider": "openai", "providers": {}}); + let default: LashConfig = serde_json::from_value(value.clone()).unwrap(); + assert_eq!( + default.charge_safety, + lash::ChargeSafetyPolicy::RequireGuarantee + ); + value["charge_safety"] = serde_json::json!({"mode": "accept_duplicate_billing", + "max_unsafe_retries": 3, "max_duplicate_cost_tokens": 1000}); + let config: LashConfig = serde_json::from_value(value.clone()).unwrap(); + assert_eq!( + config.charge_safety, + lash::ChargeSafetyPolicy::AcceptDuplicateBilling { + max_unsafe_retries: 3, + max_duplicate_cost_tokens: Some(1000), + } + ); + assert_eq!( + serde_json::to_value(&config).unwrap()["charge_safety"], + value["charge_safety"] + ); + value["charge_safety"] = serde_json::json!({"mode": "accept_duplicate_billing"}); + assert!(serde_json::from_value::(value).is_err()); + } + #[test] fn load_outcome_reports_missing_config() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/lash-cli/src/startup/mod.rs b/crates/lash-cli/src/startup/mod.rs index 9596fb3..7f5ff20 100644 --- a/crates/lash-cli/src/startup/mod.rs +++ b/crates/lash-cli/src/startup/mod.rs @@ -460,6 +460,7 @@ pub(crate) async fn run(args: Args) -> anyhow::Result<()> { session_policy.provider_id = active_provider.kind().to_string(); session_policy.session_id = run_session_id.clone(); session_policy.autonomous = autonomous; + session_policy.charge_safety = lash_config.charge_safety.clone(); 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(); @@ -562,6 +563,7 @@ pub(crate) async fn run(args: Args) -> anyhow::Result<()> { trace_path, trace_level, !autonomous, + lash_config.charge_safety.clone(), ); let session_bootstrap = session_bootstrap.expect("session bootstrap is opened for every non --info run"); diff --git a/crates/lash-cli/src/startup/session.rs b/crates/lash-cli/src/startup/session.rs index 9b59bd6..374b6aa 100644 --- a/crates/lash-cli/src/startup/session.rs +++ b/crates/lash-cli/src/startup/session.rs @@ -84,6 +84,7 @@ pub(crate) struct CliSessionOpener { trace_jsonl_path: Option, trace_level: lash::tracing::TraceLevel, owns_visible_queued_turns: bool, + charge_safety: lash::ChargeSafetyPolicy, opened_cores: Arc>>, } @@ -163,6 +164,7 @@ impl CliSessionOpener { trace_jsonl_path: Option, trace_level: lash::tracing::TraceLevel, owns_visible_queued_turns: bool, + charge_safety: lash::ChargeSafetyPolicy, ) -> Self { Self { plugin_stack, @@ -173,6 +175,7 @@ impl CliSessionOpener { trace_jsonl_path, trace_level, owns_visible_queued_turns, + charge_safety, opened_cores: Arc::new(tokio::sync::Mutex::new(Vec::new())), } } @@ -296,6 +299,7 @@ impl CliSessionOpener { } }; let mut builder = builder + .charge_safety(self.charge_safety.clone()) .provider(self.provider.clone()) .model(fallback_policy.model.clone()) .no_progress_budget(crate::host_policy::no_progress_budget()) @@ -338,6 +342,7 @@ impl CliSessionOpener { } let session_spec = SessionSpec::new() + .charge_safety(self.charge_safety.clone()) .provider_id(fallback_policy.provider_id.clone()) .model(fallback_policy.model.clone()) .turn_budget(crate::host_policy::turn_budget()) From c336819e2796573b4db603507bd37d2e4ca2fe67 Mon Sep 17 00:00:00 2001 From: Samuel Galanakis Date: Tue, 8 Sep 2026 10:57:41 +0200 Subject: [PATCH 4/7] Document the whole-home cutover and retry policy contracts Update session, trace, and remote stamps and derive their test expectations from runtime exports. Explain reset scope, charge safety, retry jitter and Retry-After refusal, and verify refusal without old-home mutation plus charge policy reapplication on fresh and resumed sessions. Release-Notes: Document runtime compatibility and charge policy, with end-to-end reset and retry coverage. --- crates/lash-cli/src/startup/preflight.rs | 10 +- crates/lash-cli/tests/cli_e2e.rs | 182 +++++++++++++++++++++++ docs/architecture.html | 5 +- docs/index.html | 20 ++- 4 files changed, 211 insertions(+), 6 deletions(-) diff --git a/crates/lash-cli/src/startup/preflight.rs b/crates/lash-cli/src/startup/preflight.rs index 19069f4..2786adb 100644 --- a/crates/lash-cli/src/startup/preflight.rs +++ b/crates/lash-cli/src/startup/preflight.rs @@ -12,7 +12,9 @@ pub(super) async fn probe_runtime_store() -> anyhow::Result anyhow::Result Ok(false) } -/// `--reset`: confirm, then delete the unified store and host session roster. +/// `--reset`: confirm a whole-home runtime-data reset, preserving configuration. fn run_reset() -> anyhow::Result<()> { use std::io::Write; @@ -57,7 +59,9 @@ fn run_reset() -> anyhow::Result<()> { eprintln!(); eprintln!(" {SODIUM}{BOLD}/ reset{RESET}"); eprintln!(); - eprintln!(" {ERR}This will permanently delete Lash runtime data:{RESET}"); + eprintln!( + " {ERR}This whole-home reset will permanently delete all Lash runtime databases and the session roster:{RESET}" + ); eprintln!(); eprintln!( " {ASH_TEXT}durable store {CHALK}{}{RESET}", diff --git a/crates/lash-cli/tests/cli_e2e.rs b/crates/lash-cli/tests/cli_e2e.rs index 9fb095c..7f338e2 100644 --- a/crates/lash-cli/tests/cli_e2e.rs +++ b/crates/lash-cli/tests/cli_e2e.rs @@ -7,6 +7,188 @@ use lash_debug_cli_harness::{ ExecutionMode, HarnessConfig, LiveHarness, MouseHarnessEvent, repo_root_from_manifest_dir, }; +#[test] +fn charge_safety_is_reapplied_on_fresh_and_resumed_autonomous_sessions() { + let temp = tempfile::tempdir().expect("temp lash home"); + write_test_provider_config(temp.path(), "charge-safety"); + write_test_model_catalog(temp.path()); + let run = |resume: Option<&str>| { + let mut command = Command::new(lash_bin()); + command + .env("LASH_HOME", temp.path()) + .env("LASH_LOG", "error") + .args([ + "--model", + "test/cli-e2e-model", + "--print", + "check charge policy", + ]); + if let Some(session) = resume { + command.args(["--resume", session]); + } + run_lash_with_timeout(&mut command, Duration::from_secs(30)) + }; + let refused = run(None); + assert!(!refused.status.success()); + let stderr = String::from_utf8_lossy(&refused.stderr); + assert!( + stderr.contains("unsafe_retry_after_output_started"), + "{stderr}" + ); + assert!( + stderr.contains("billed tokens 0 (reported or defaulted"), + "{stderr}" + ); + assert!(stderr.contains("partial output present"), "{stderr}"); + assert!(!String::from_utf8_lossy(&refused.stdout).contains("PRIVATE PARTIAL")); + assert!(!stderr.contains("PRIVATE PARTIAL")); + let session = std::fs::read_dir(temp.path().join("sessions")) + .unwrap() + .filter_map(Result::ok) + .find_map(|entry| { + entry + .file_name() + .to_str() + .and_then(|name| name.strip_suffix(".ui.json")) + .map(str::to_owned) + }) + .unwrap(); + let path = temp.path().join("config.json"); + let mut config: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + config["charge_safety"] = serde_json::json!({"mode": "accept_duplicate_billing", + "max_unsafe_retries": 1, "max_duplicate_cost_tokens": 100}); + std::fs::write(&path, serde_json::to_vec(&config).unwrap()).unwrap(); + for resume in [Some(session.as_str()), None] { + let retried = run(resume); + assert!( + retried.status.success(), + "{}", + String::from_utf8_lossy(&retried.stderr) + ); + assert!(String::from_utf8_lossy(&retried.stdout).contains("charge-safe retry completed")); + } + config.as_object_mut().unwrap().remove("charge_safety"); + std::fs::write(&path, serde_json::to_vec(&config).unwrap()).unwrap(); + let refused_again = run(Some(&session)); + assert!(!refused_again.status.success()); + assert!( + String::from_utf8_lossy(&refused_again.stderr) + .contains("unsafe_retry_after_output_started") + ); +} + +#[test] +fn old_home_preflight_refuses_without_mutation_then_reset_clears_all_runtime_data() { + use lash_sqlite_store::SqliteDatabase; + use std::io::Write as _; + let temp = tempfile::tempdir().expect("temp lash home"); + let store = temp.path().join("store"); + std::fs::create_dir_all(&store).unwrap(); + std::fs::create_dir_all(temp.path().join("sessions")).unwrap(); + std::fs::write(temp.path().join("sessions/old.ui.json"), "preserved roster").unwrap(); + write_test_provider_config(temp.path(), "standard-slow-echo"); + write_test_model_catalog(temp.path()); + let config = std::fs::read(temp.path().join("config.json")).unwrap(); + let mut fixtures = Vec::new(); + for (file, database, bumps) in [ + ("durable-core.db", SqliteDatabase::DurableCore, 5), + ("processes.db", SqliteDatabase::ProcessRegistry, 6), + ("triggers.db", SqliteDatabase::Triggers, 1), + ("effects.db", SqliteDatabase::EffectReplay, 2), + ] { + // Reconstruct the previous pin's versions from the reviewed bump counts. + let old = database.expected_version() - bumps; + let path = store.join(file); + assert!(Command::new("python3").args(["-c", + "import sqlite3,sys; c=sqlite3.connect(sys.argv[1]); c.execute('PRAGMA user_version='+sys.argv[2]); c.execute('CREATE TABLE preserved(value TEXT)'); c.execute(\"INSERT INTO preserved VALUES ('old home')\"); c.commit(); c.close()"]) + .arg(&path).arg(old.to_string()).status().unwrap().success()); + fixtures.push((path.clone(), std::fs::read(&path).unwrap(), database, old)); + } + let refused = run_lash_with_timeout( + Command::new(lash_bin()) + .env("LASH_HOME", temp.path()) + .args(["--info", "--model", "test/cli-e2e-model"]), + Duration::from_secs(20), + ); + assert!(!refused.status.success()); + let stderr = String::from_utf8_lossy(&refused.stderr); + for (path, bytes, database, old) in fixtures { + assert!( + stderr.contains(&format!( + "schema `{}` is at version {} and this build requires {}", + database.name(), + old, + database.expected_version() + )), + "{stderr}" + ); + assert_eq!( + std::fs::read(path).unwrap(), + bytes, + "preflight mutated an old database" + ); + } + assert!(stderr.contains("whole-home")); + assert!(stderr.contains("lash --reset")); + let mut reset = Command::new(lash_bin()) + .env("LASH_HOME", temp.path()) + .arg("--reset") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + reset.stdin.take().unwrap().write_all(b"y\n").unwrap(); + let output = reset.wait_with_output().unwrap(); + assert!(output.status.success()); + assert!(!store.exists()); + assert!(!temp.path().join("sessions").exists()); + assert_eq!( + std::fs::read(temp.path().join("config.json")).unwrap(), + config + ); + let ready = run_lash_with_timeout( + Command::new(lash_bin()) + .env("LASH_HOME", temp.path()) + .args(["--info", "--model", "test/cli-e2e-model"]), + Duration::from_secs(20), + ); + assert!( + ready.status.success(), + "{}", + String::from_utf8_lossy(&ready.stderr) + ); +} + +#[test] +fn published_documentation_versions_follow_runtime_exports() { + for page in ["index.html", "architecture.html"] { + let text = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../docs") + .join(page), + ) + .unwrap(); + for (label, version) in [ + ( + "session schema", + lash_sqlite_store::SESSION_SCHEMA_VERSION.to_string(), + ), + ("trace schema", lash_trace::TRACE_SCHEMA_VERSION.to_string()), + ( + "remote protocol", + lash::remote::REMOTE_PROTOCOL_VERSION.to_string(), + ), + ] { + assert!( + text.contains(&format!("{label} {version}")), + "{page}: {label} is stale" + ); + } + } +} + #[test] fn cli_short_rlm_mode_runs_subagent_spawn_with_test_provider() { let temp = tempfile::tempdir().expect("temp lash home"); diff --git a/docs/architecture.html b/docs/architecture.html index 9af9e6f..df5022c 100644 --- a/docs/architecture.html +++ b/docs/architecture.html @@ -64,7 +64,10 @@

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 45, trace schema 10, and remote protocol 49.

+

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 50, trace schema 18, and remote protocol 54.

+

Compatibility is a forward-only whole-home runtime-data cutover, including process, trigger, and effect-replay stores. Preflight refuses older homes without migration. After retaining needed data with the old binary, lash --reset clears the entire runtime store and host roster together while preserving configuration; individual database resets are unsupported.

+

The shared session opener reapplies charge_safety as live host policy on creation and reopen and supplies it to core defaults for child sessions and native queued work. Interactive cores still disable automatic queued execution; catalog refreshes drain their selected receipt and refuse model turns. Dock snapshots use filtered session observations with the existing ten-second retirement horizon and incarnation-aware identity. The observer registry still scans all observed records before filtering; the result horizon and batched lease read mitigate polling cost.

+

Attachment admission and unsupported-format diagnostics consume the model's shared attachment capability snapshot. RLM dialect decoding is fallible and remains session-scoped. RLM memory limits measure logical heap bytes, not resident process memory.

Dependency rule

diff --git a/docs/index.html b/docs/index.html index 8280045..99fe1ad 100644 --- a/docs/index.html +++ b/docs/index.html @@ -186,7 +186,7 @@

Debug & tracing

Admin

-
--reset
Delete ~/.lash/store/ and the host session roster under ~/.lash/sessions/, preserving provider configuration.
+
--reset
Perform a whole-home runtime-data reset: delete all databases and attachments in ~/.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 <session-id>
Export a session from the unified Lash catalog. Combine with --export-format html|json, --export-trace <jsonl>, and --export-out <path>.
@@ -234,6 +234,7 @@

Config File

{
   "active_provider": "openai",
+  "charge_safety": { "mode": "require_guarantee" },
   "execution_mode": "rlm",
   "rlm_dialect": "typescript",
   "providers": {
@@ -273,6 +274,7 @@ 

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), max_output_tokens, and reliability.response_start_timeout in milliseconds. Omit the response-start timeout or set it to 0 to retain the legacy min(request, chunk) bound. + charge_safetyobjectLive host retry policy. Defaults to {"mode":"require_guarantee"} when unset. Applied on every creation and reopen, including /clear, /resume, autonomous runs, and native queued work; it is excluded from durable session policy serialization. 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. @@ -284,6 +286,19 @@

Config File

+
+

Retries & charge safety

+

By default, Lash requires a provider idempotency or resume guarantee before retrying a generation that may already have been billed. To accept bounded duplicate billing, set this top-level config key:

+
"charge_safety": {
+  "mode": "accept_duplicate_billing",
+  "max_unsafe_retries": 2,
+  "max_duplicate_cost_tokens": 2000
+}
+

Lash clamps max_unsafe_retries to at most five per logical LLM call. The optional token bound counts input, output, cache-read, and cache-write tokens already billed for the abandoned generation. Missing partial usage counts as zero toward this bound, so it cannot cap unknown charges; the retry-count limit still applies. Omit max_duplicate_cost_tokens or set it to null for no token bound. Nonretryable failures remain nonretryable. This is configuration, with no CLI flag.

+

Provider retry settings live under providers.<kind>.options.reliability.retry. ProviderRetryPolicy.jitter_ms defaults to 500 milliseconds of per-attempt additive random jitter; 0 disables it. retry_after_cap_ms defaults to 60000 (60 seconds). A longer server Retry-After now refuses immediately instead of sleeping a truncated delay.

+

Failed-turn diagnostics show the charge refusal code, billed tokens, and whether partial output exists. Zero usage is labeled as reported or defaulted because the evidence does not distinguish missing usage from a reported zero. Partial text stays out of the terminal timeline. Advisory issues render as warnings, and omitted tool records disclose their counts. JSON/RPC finish records also carry failure_evidence and omitted.

+
+

Environment Variables

@@ -316,8 +331,9 @@

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 45, trace schema 10, and remote protocol 49. lash --version and /info report them.

+

The current durable compatibility markers are session schema 50, trace schema 18, and remote protocol 54. lash --version and /info report them.

+

This pin is a forward-only, whole-home runtime-data cutover. Older session, process, trigger, and effect-replay databases are refused before provider startup; there is no migration chain. Retain or export needed data with the old compatible binary first. Then run lash --reset and confirm to remove the entire runtime store and session roster together, preserving provider configuration. Never reset databases piecemeal. Old trace files need a compatible reader; remote peers need matching protocol versions.

/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 19971fa7c4d88762b1412ba19593de4e81183460 Mon Sep 17 00:00:00 2001 From: Samuel Galanakis Date: Tue, 8 Sep 2026 11:24:00 +0200 Subject: [PATCH 5/7] Remove plan mode from lash-cli Delete the plan-mode plugin, /plan UI extension, plan dock state and rendering, and feature-specific activity and test coverage. Keep the generic extension seam and process-dock behavior intact. Release-Notes: removed plan mode and the /plan command --- Cargo.toml | 1 - crates/lash-cli/Cargo.toml | 1 - .../lash-cli/src/activity/projectors/mod.rs | 2 - .../src/activity/projectors/update_plan.rs | 191 ----------- crates/lash-cli/src/app/mod.rs | 41 --- crates/lash-cli/src/app/runtime_events.rs | 26 +- crates/lash-cli/src/app/view.rs | 3 - crates/lash-cli/src/main.rs | 1 - crates/lash-cli/src/plan_plugin.rs | 136 -------- crates/lash-cli/src/plugin_surface.rs | 219 ------------ .../lash-cli/src/render/compositor/history.rs | 19 -- .../lash-cli/src/render/compositor/tests.rs | 45 +-- crates/lash-cli/src/render/sections/docks.rs | 65 ---- crates/lash-cli/src/render/sections/layout.rs | 11 - crates/lash-cli/src/render/tests.rs | 140 ++------ crates/lash-cli/src/startup/plugins.rs | 20 +- .../lash-cli/src/ui_perf/projection_cases.rs | 18 +- crates/lash-tui-extensions/src/lib.rs | 322 +----------------- runbooks/process-dock-lifecycle/runbook.md | 4 +- 19 files changed, 53 insertions(+), 1212 deletions(-) delete mode 100644 crates/lash-cli/src/activity/projectors/update_plan.rs delete mode 100644 crates/lash-cli/src/plan_plugin.rs diff --git a/Cargo.toml b/Cargo.toml index 1a476fd..2e0c579 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,7 +53,6 @@ lash = { package = "lash-runtime", git = "https://github.com/Ascending-AI/lash.g lash-lashlang-runtime = { package = "lash-internal-lashlang-runtime", git = "https://github.com/Ascending-AI/lash.git", rev = "46dfed8ea406738588cb6a170a1d6825154267cd" } lash-llm-tools = { package = "lash-internal-llm-tools", git = "https://github.com/Ascending-AI/lash.git", rev = "46dfed8ea406738588cb6a170a1d6825154267cd" } lash-plugin-mcp = { package = "lash-internal-plugin-mcp", git = "https://github.com/Ascending-AI/lash.git", rev = "46dfed8ea406738588cb6a170a1d6825154267cd" } -# lash-plugin-plan-mode removed upstream lash-plugin-process-controls = { package = "lash-internal-plugin-process-controls", git = "https://github.com/Ascending-AI/lash.git", rev = "46dfed8ea406738588cb6a170a1d6825154267cd" } lash-protocol-rlm = { package = "lash-internal-protocol-rlm", git = "https://github.com/Ascending-AI/lash.git", rev = "46dfed8ea406738588cb6a170a1d6825154267cd" } lash-protocol-standard = { package = "lash-internal-protocol-standard", git = "https://github.com/Ascending-AI/lash.git", rev = "46dfed8ea406738588cb6a170a1d6825154267cd" } diff --git a/crates/lash-cli/Cargo.toml b/crates/lash-cli/Cargo.toml index c7ddab1..2b9d730 100644 --- a/crates/lash-cli/Cargo.toml +++ b/crates/lash-cli/Cargo.toml @@ -37,7 +37,6 @@ 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 = removed upstream lash-provider-anthropic = { workspace = true } lash-provider-auth = { workspace = true } lash-provider-google = { workspace = true } diff --git a/crates/lash-cli/src/activity/projectors/mod.rs b/crates/lash-cli/src/activity/projectors/mod.rs index 8195b9c..3d70cad 100644 --- a/crates/lash-cli/src/activity/projectors/mod.rs +++ b/crates/lash-cli/src/activity/projectors/mod.rs @@ -20,7 +20,6 @@ pub(crate) mod lashlang; pub(crate) mod process; pub(crate) mod shell; pub(crate) mod subagents; -pub(crate) mod update_plan; pub(crate) mod web; /// Register every built-in projector with the given `ActivityState`. @@ -38,6 +37,5 @@ pub(super) fn register_builtins(state: &mut ActivityState) { state.register(process::ProcessProjector); state.register(shell::ShellProjector); state.register(subagents::SubagentProjector); - state.register(update_plan::UpdatePlanProjector); state.register(web::WebProjector); } diff --git a/crates/lash-cli/src/activity/projectors/update_plan.rs b/crates/lash-cli/src/activity/projectors/update_plan.rs deleted file mode 100644 index 9fda757..0000000 --- a/crates/lash-cli/src/activity/projectors/update_plan.rs +++ /dev/null @@ -1,191 +0,0 @@ -//! `update_plan` projector. -//! -//! The `update_plan` tool returns a flat `"Plan updated"` string, which -//! renders as an empty expanded activity when the user hits the expand -//! key. Surface the submitted plan items as detail lines so the -//! expanded view shows what the model actually pushed to the dock. - -use serde_json::Value; - -use crate::activity::{ - ActivityBlock, ActivityKind, ActivityStatus, ProjectCtx, ToolProjector, - shared::{inline_text, tool_arg_str}, -}; - -pub(crate) struct UpdatePlanProjector; - -impl ToolProjector for UpdatePlanProjector { - fn tool_names(&self) -> &'static [&'static str] { - &["update_plan"] - } - - fn project(&self, ctx: &mut ProjectCtx<'_>) -> Vec { - if ctx.name != "update_plan" { - return Vec::new(); - } - let status = if ctx.success { - ActivityStatus::Completed - } else { - ActivityStatus::Failed - }; - let summary = update_plan_summary(&ctx.args); - // On success the sticky plan dock already renders the - // checklist — repeating every step as activity detail lines - // just stacks the same information inline above it. Keep - // detail lines only on failure, where the expanded activity - // has to explain *why* the call was rejected. - let detail_lines = if ctx.success { - Vec::new() - } else { - update_plan_failure_lines(&ctx.args, &ctx.result) - }; - let args = std::mem::replace(&mut ctx.args, Value::Null); - let result = std::mem::replace(&mut ctx.result, Value::Null); - vec![ - ActivityBlock::new( - ActivityKind::GenericTool, - ctx.name, - args, - summary, - status, - result, - ctx.duration_ms, - ) - .with_detail_lines(detail_lines), - ] - } -} - -fn update_plan_summary(args: &Value) -> String { - let count = args - .get("plan") - .and_then(Value::as_array) - .map(Vec::len) - .unwrap_or(0); - match count { - 0 => "update plan".to_string(), - 1 => "update plan · 1 step".to_string(), - n => format!("update plan · {n} steps"), - } -} - -/// Render each plan item as a detail line prefixed with the same glyph -/// the sticky dock uses (`✓` done, `■` in-progress, `□` pending), so the -/// expanded activity mirrors what just landed in the dock. -fn update_plan_detail_lines(args: &Value) -> Vec { - let mut lines = Vec::new(); - if let Some(explanation) = tool_arg_str(args, "explanation") { - lines.push(inline_text(explanation)); - } - let Some(plan) = args.get("plan").and_then(Value::as_array) else { - return lines; - }; - for item in plan { - let step = item - .get("step") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or("(empty)"); - let status = item.get("status").and_then(Value::as_str).unwrap_or(""); - let glyph = match status { - "completed" => "✓", - "in_progress" => "■", - _ => "□", - }; - lines.push(format!("{glyph} {}", inline_text(step))); - } - lines -} - -/// When `update_plan` rejects the args, surface the validation error -/// first so the expanded activity explains the red `×` instead of -/// silently listing the plan the model tried to submit. -fn update_plan_failure_lines(args: &Value, result: &Value) -> Vec { - let mut lines = Vec::new(); - if let Some(message) = result - .as_str() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - lines.push(inline_text(message)); - } else if let Some(message) = result - .get("error") - .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)); - } 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 -} - -#[cfg(test)] -mod tests { - use crate::activity::ActivityState; - use serde_json::json; - - #[test] - fn update_plan_success_has_no_detail_lines_so_dock_is_the_only_render() { - let mut state = ActivityState::new(); - let blocks = state.project_tool_call( - "update_plan", - json!({ - "explanation": "Cooking-show vibes.", - "plan": [ - {"step": "Find a suspiciously ripe tomato", "status": "completed"}, - {"step": "Chop onions while pretending to be on a cooking show", "status": "in_progress"}, - {"step": "Simmer everything until it smells impressive", "status": "pending"}, - ] - }), - json!("Plan updated"), - true, - 3, - ); - - assert_eq!(blocks.len(), 1); - assert_eq!(blocks[0].call.summary, "update plan · 3 steps"); - assert!( - blocks[0].result.detail_lines.is_empty(), - "success path must not duplicate the plan dock's content", - ); - } - - #[test] - fn update_plan_failure_surfaces_error_before_submitted_plan() { - let mut state = ActivityState::new(); - let blocks = state.project_tool_call( - "update_plan", - json!({ - "plan": [ - {"step": "A", "status": "in_progress"}, - {"step": "B", "status": "in_progress"} - ] - }), - json!("Plan may contain at most one in_progress step"), - false, - 2, - ); - - assert_eq!( - blocks[0].result.detail_lines, - vec![ - "Plan may contain at most one in_progress step".to_string(), - "■ A".to_string(), - "■ B".to_string(), - ] - ); - } -} diff --git a/crates/lash-cli/src/app/mod.rs b/crates/lash-cli/src/app/mod.rs index 9fbc266..6f8c849 100644 --- a/crates/lash-cli/src/app/mod.rs +++ b/crates/lash-cli/src/app/mod.rs @@ -79,42 +79,6 @@ pub struct PluginPanelBlock { pub content: String, } -/// One row in the sticky plan dock. `status` drives the glyph + color: -/// `✓` lichen for `Done`, `■` sodium for `Active` (at most one), `□` -/// chalk-dim for `Pending`. -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct PlanDockItem { - pub text: String, - pub status: PlanDockItemStatus, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PlanDockItemStatus { - Done, - Active, - Pending, -} - -/// The persistent plan companion rendered at the bottom of the TUI -/// frame. Populated by the `plan_mode` plugin's panel events and -/// cleared when the plan is dismissed. See `docs/design-language.html`. -#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct PlanDockState { - pub title: String, - /// Optional meta line shown alongside the title (e.g. `3m 3s · ↓ 1.7k tokens · thinking`). - /// Rendered in ash-text next to the title. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub meta: Option, - pub items: Vec, -} - -impl PlanDockState { - pub fn is_empty(&self) -> bool { - self.items.is_empty() && self.title.trim().is_empty() - } -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ToastKind { Info, @@ -652,10 +616,6 @@ pub struct App { pub repo_status: Option, /// Active plugin-owned mode indicators rendered in the input chrome. pub plugin_mode_indicators: BTreeMap, - /// Active plan surfaced by the `plan_mode` plugin. When present it - /// renders as a sticky dock between the history viewport and the - /// input row instead of as an inline panel in the scroll. - pub plan_dock: Option, /// Snapshot of processes registered for this session. pub processes: Vec, /// Focused background process row in the trailing process dock. @@ -935,7 +895,6 @@ impl App { .ok() .and_then(|cwd| crate::repo_status::detect_repo_status(&cwd)), plugin_mode_indicators: BTreeMap::new(), - plan_dock: None, processes: Vec::new(), selected_process_ref: None, ui_extensions: Arc::new(TuiExtensions::default()), diff --git a/crates/lash-cli/src/app/runtime_events.rs b/crates/lash-cli/src/app/runtime_events.rs index 148c9c0..a3efb01 100644 --- a/crates/lash-cli/src/app/runtime_events.rs +++ b/crates/lash-cli/src/app/runtime_events.rs @@ -454,35 +454,11 @@ impl App { // Lash owns child aggregation. The completed `TurnReport` and // `LashSession::usage_report` refresh the UI from canonical data. TurnEvent::ChildUsage { .. } => {} - TurnEvent::PluginRuntime { plugin_id, event } => { + TurnEvent::PluginRuntime { event, .. } => { if let Some((status, detail, duration)) = runtime_status_from_plugin_event(&event) { self.set_transient_status(status, detail, duration); self.dirty = true; } - let renders_visible_output = - crate::plugin_surface::event_renders_visible_output(&event); - let mutation = crate::plugin_surface::apply_surface_event( - &mut self.timeline, - &mut self.plugin_mode_indicators, - &self.plan_dock, - &plugin_id, - event, - ); - if mutation.blocks_changed { - self.invalidate_height_cache(); - if renders_visible_output { - self.mark_visible_output(); - } - self.scroll_to_bottom(); - } - if mutation.indicators_changed { - self.dirty = true; - } - if let Some(next_dock) = mutation.plan_dock_change { - self.plan_dock = next_dock.filter(|state| !state.is_empty()); - self.invalidate_height_cache(); - self.dirty = true; - } } TurnEvent::QueuedInputAccepted { applications } => { self.reconcile_turn_input_applications(&applications); diff --git a/crates/lash-cli/src/app/view.rs b/crates/lash-cli/src/app/view.rs index 136e1ed..53689aa 100644 --- a/crates/lash-cli/src/app/view.rs +++ b/crates/lash-cli/src/app/view.rs @@ -44,7 +44,6 @@ impl App { session_name, session_id, plugin_mode_indicators, - plan_dock, processes, selected_process_ref, activity_state, @@ -90,7 +89,6 @@ impl App { session_name.clear(); session_id.clear(); plugin_mode_indicators.clear(); - *plan_dock = None; processes.clear(); *selected_process_ref = None; activity_state.reset(); @@ -521,7 +519,6 @@ impl App { + crate::render::live_tool_output_standalone_height(self, width) + self.live_reasoning_height() + self.live_assistant_height() - + crate::render::plan_dock_trailing_height(self) } } diff --git a/crates/lash-cli/src/main.rs b/crates/lash-cli/src/main.rs index 5276544..648b976 100644 --- a/crates/lash-cli/src/main.rs +++ b/crates/lash-cli/src/main.rs @@ -25,7 +25,6 @@ mod model_catalog; mod model_selection; mod overlay; mod paths; -mod plan_plugin; mod plugin_surface; mod prompt_context_plugin; mod prompt_model; diff --git a/crates/lash-cli/src/plan_plugin.rs b/crates/lash-cli/src/plan_plugin.rs deleted file mode 100644 index 23c9741..0000000 --- a/crates/lash-cli/src/plan_plugin.rs +++ /dev/null @@ -1,136 +0,0 @@ -use std::sync::Arc; - -use lash::plugins::{ - PluginDirective, PluginError, PluginFactory, PluginRegistrar, PluginRuntimeEvent, - PluginSessionContext, SessionPlugin, -}; -use lash::tools::ToolDefinitionBindingExt; -use lash::tools::{ - ToolCall, ToolContract, ToolDefinition, ToolManifest, ToolOutcome, ToolProvider, -}; -use lash_tool_support::{object_schema, tool_binding, typed_args, typed_ok}; -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()))?, - }]) - .into(), - ]) - }) - })); - 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 { - let snapshot = match typed_args::(call.args) { - Ok(snapshot) => snapshot, - Err(outcome) => return outcome, - }; - typed_ok(serde_json::json!({ - "generation": snapshot.generation, - })) - } -} - -fn update_plan_definition() -> ToolDefinition { - ToolDefinition::raw( - "tool:update_plan", - "update_plan", - "Publish or replace the current task plan.", - object_schema( - serde_json::json!({ - "explanation": {"type": ["string", "null"]}, - "generation": {"type": "integer", "minimum": 0}, - "plan": { - "type": "array", - "items": object_schema( - serde_json::json!({ - "step": {"type": "string"}, - "status": {"enum": ["pending", "in_progress", "completed"]} - }), - &["step", "status"], - ) - } - }), - &["plan"], - ), - object_schema( - serde_json::json!({"generation": {"type": "integer"}}), - &["generation"], - ), - ) - .with_tool_binding(tool_binding(["plan"], "update", &[])) -} diff --git a/crates/lash-cli/src/plugin_surface.rs b/crates/lash-cli/src/plugin_surface.rs index 30e6151..09eb397 100644 --- a/crates/lash-cli/src/plugin_surface.rs +++ b/crates/lash-cli/src/plugin_surface.rs @@ -1,222 +1,3 @@ -use std::collections::BTreeMap; - -use lash::plugins::PluginRuntimeEvent; - -use crate::app::{PlanDockItem, PlanDockItemStatus, PlanDockState, UiTimeline}; - -pub struct PluginRuntimeMutation { - pub blocks_changed: bool, - pub indicators_changed: bool, - /// Side-channel update for the bottom-anchored plan dock. When set, - /// replaces the dock state wholesale (`Some(None)` clears the - /// current dock, `Some(Some(state))` installs a fresh plan). - pub plan_dock_change: Option>, -} - -impl PluginRuntimeMutation { - fn blocks_changed(changed: bool) -> Self { - Self { - blocks_changed: changed, - indicators_changed: false, - plan_dock_change: None, - } - } - - fn plan_dock_update(update: Option) -> Self { - Self { - blocks_changed: false, - indicators_changed: false, - plan_dock_change: Some(update), - } - } -} - pub fn surface_key(plugin_id: &str, key: &str) -> String { format!("{plugin_id}:{key}") } - -pub(crate) fn event_renders_visible_output(event: &PluginRuntimeEvent) -> bool { - match event { - PluginRuntimeEvent::Custom { name, .. } => name == crate::plan_plugin::PLAN_EVENT, - PluginRuntimeEvent::Status { .. } => false, - } -} - -fn plan_state_from_snapshot(snapshot: crate::plan_plugin::PlanSnapshot) -> PlanDockState { - PlanDockState { - title: "PLAN".to_string(), - meta: snapshot.explanation, - items: snapshot - .plan - .into_iter() - .map(|item| PlanDockItem { - text: item.step, - status: match item.status.as_str() { - "completed" => PlanDockItemStatus::Done, - "in_progress" => PlanDockItemStatus::Active, - _ => PlanDockItemStatus::Pending, - }, - }) - .collect(), - } -} - -pub fn apply_surface_event( - _blocks: &mut UiTimeline, - _indicators: &mut BTreeMap, - _plan_dock: &Option, - plugin_id: &str, - event: PluginRuntimeEvent, -) -> PluginRuntimeMutation { - match event { - PluginRuntimeEvent::Status { .. } => PluginRuntimeMutation::blocks_changed(false), - PluginRuntimeEvent::Custom { name, payload } => { - if plugin_id == crate::plan_plugin::PLUGIN_ID && name == crate::plan_plugin::PLAN_EVENT - { - let Ok(snapshot) = - serde_json::from_value::(payload) - else { - return PluginRuntimeMutation::blocks_changed(false); - }; - let next = plan_state_from_snapshot(snapshot); - let all_done = !next.items.is_empty() - && next - .items - .iter() - .all(|item| item.status == PlanDockItemStatus::Done); - if next.items.is_empty() || all_done { - return PluginRuntimeMutation::plan_dock_update(None); - } - return PluginRuntimeMutation::plan_dock_update(Some(next)); - } - PluginRuntimeMutation::blocks_changed(false) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::plan_plugin::{PlanItem, PlanSnapshot}; - - fn custom(name: &str, payload: impl serde::Serialize) -> PluginRuntimeEvent { - PluginRuntimeEvent::Custom { - name: name.to_string(), - payload: serde_json::to_value(payload).expect("payload"), - } - } - - #[test] - fn update_plan_snapshot_routes_to_dock() { - let mut blocks = UiTimeline::default(); - let mut indicators = BTreeMap::new(); - let dock: Option = None; - - let mutation = apply_surface_event( - &mut blocks, - &mut indicators, - &dock, - "update_plan", - custom( - crate::plan_plugin::PLAN_EVENT, - PlanSnapshot { - explanation: None, - generation: 1, - plan: vec![ - PlanItem { - step: "done".into(), - status: "completed".into(), - }, - PlanItem { - step: "active".into(), - status: "in_progress".into(), - }, - PlanItem { - step: "later".into(), - status: "pending".into(), - }, - ], - }, - ), - ); - - assert!( - blocks.is_empty(), - "plan snapshot should not land in history" - ); - let next = mutation - .plan_dock_change - .expect("plan_dock_change should be set") - .expect("should carry a state"); - assert_eq!(next.title, "PLAN"); - assert_eq!(next.items.len(), 3); - assert_eq!(next.items[1].status, PlanDockItemStatus::Active); - } - - #[test] - fn all_completed_plan_auto_hides_dock() { - let mut blocks = UiTimeline::default(); - let mut indicators = BTreeMap::new(); - let dock = Some(PlanDockState { - title: "PLAN".into(), - meta: None, - items: vec![PlanDockItem { - text: "in flight".into(), - status: PlanDockItemStatus::Active, - }], - }); - - let mutation = apply_surface_event( - &mut blocks, - &mut indicators, - &dock, - "update_plan", - custom( - crate::plan_plugin::PLAN_EVENT, - PlanSnapshot { - explanation: None, - generation: 2, - plan: vec![ - PlanItem { - step: "chop".into(), - status: "completed".into(), - }, - PlanItem { - step: "simmer".into(), - status: "completed".into(), - }, - PlanItem { - step: "plate".into(), - status: "completed".into(), - }, - ], - }, - ), - ); - - assert_eq!( - mutation.plan_dock_change, - Some(None), - "dock should auto-hide once every item is completed" - ); - } - - #[test] - fn unrelated_custom_event_is_ignored() { - let mut blocks = UiTimeline::default(); - let mut indicators = BTreeMap::new(); - let dock: Option = None; - - let mutation = apply_surface_event( - &mut blocks, - &mut indicators, - &dock, - "some_plugin", - custom("some.event", serde_json::json!({"value": 1})), - ); - - assert!(!mutation.blocks_changed); - assert!(mutation.plan_dock_change.is_none()); - assert!(blocks.is_empty()); - } -} diff --git a/crates/lash-cli/src/render/compositor/history.rs b/crates/lash-cli/src/render/compositor/history.rs index 1d11c4c..2191393 100644 --- a/crates/lash-cli/src/render/compositor/history.rs +++ b/crates/lash-cli/src/render/compositor/history.rs @@ -77,24 +77,6 @@ fn draw_history(frame: &mut Frame<'_>, app: &mut App, area: Rect) { frame.write_line(area.x, base_y + written_rows as u16, line, area.width); written_rows += 1; } - // Advance skip_lines past the live markdown content rows so a - // trailing plan-dock render picks up at the right offset. - skip_lines = skip_lines.saturating_sub(live_lines.len()); - } - - // Plan checklist renders at the logical tail of history — part of - // the scroll, not a pinned dock. Appears just after the live- - // assistant trace so new turns push it down the transcript. - if written_rows < row_budget - && let Some(plan_lines) = render::plan_dock_lines_snapshot(app, area.width) - { - for line in plan_lines.iter().skip(skip_lines) { - if written_rows >= row_budget { - break; - } - frame.write_line(area.x, base_y + written_rows as u16, line, area.width); - written_rows += 1; - } } if let Some((x, y, height)) = render::history_scroll_indicator(app, area) { @@ -107,7 +89,6 @@ fn draw_history(frame: &mut Frame<'_>, app: &mut App, area: Rect) { && app.live_tool_output_anchor_block_index().is_none() && !app.live.reasoning.has_renderable_output() && !app.live.assistant.has_renderable_output() - && app.plan_dock.as_ref().is_none_or(|plan| plan.is_empty()) { draw_empty_history_state(frame, app, area); } diff --git a/crates/lash-cli/src/render/compositor/tests.rs b/crates/lash-cli/src/render/compositor/tests.rs index fd0865f..6530ee6 100644 --- a/crates/lash-cli/src/render/compositor/tests.rs +++ b/crates/lash-cli/src/render/compositor/tests.rs @@ -473,8 +473,8 @@ mod tests { let (response_tx, _response_rx) = mpsc::channel(); app.show_prompt(PromptState { request: PromptRequest::single( - "Plan .lash/plans/15d5a2bd-841d-4729-8968-ae7874385e16.md is ready. Exit plan mode now?", - vec!["Exit plan mode".into(), "Keep planning".into()], + "Review workspace/15d5a2bd-841d-4729-8968-ae7874385e16.md is ready. Exit review mode now?", + vec!["Exit review mode".into(), "Keep reviewing".into()], ) .with_optional_note(), focus: PromptFocus::Options, @@ -488,7 +488,7 @@ mod tests { let snapshot = lash_tui::render_snapshot(84, 14, |frame| draw(frame, &mut app)); let visible = snapshot.visible_lines_trimmed().join("\n"); - assert!(visible.contains("Plan .lash/plans/15d5a2bd-841d-4729-8968-ae7874385e16.md")); + assert!(visible.contains("Review workspace/15d5a2bd-841d-4729-8968-ae7874385e16.md")); assert!(visible.contains("Choices")); assert!(!visible.contains("Question")); assert!(!visible.contains("┌")); @@ -499,10 +499,10 @@ mod tests { let mut app = App::new("gpt-5.4".into(), "test".into(), "test-session-id".into()); let (response_tx, _response_rx) = mpsc::channel(); app.show_prompt(PromptState { - request: PromptRequest::single("Exit plan mode?", vec!["Exit".into()]) + request: PromptRequest::single("Review mode?", vec!["Exit".into()]) .with_markdown_panel( - "PLAN", - "# Plan\n\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12", + "REVIEW", + "# Review\n\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12", ), focus: PromptFocus::Options, cursor: 0, @@ -724,8 +724,8 @@ mod tests { } #[test] - fn short_transcript_bottom_anchors_against_input_with_plan_gutter() { - use crate::app::{PlanDockItem, PlanDockItemStatus, PlanDockState, UiTimelineItem}; + fn short_transcript_bottom_anchors_against_input() { + use crate::app::UiTimelineItem; let mut app = App::new("gpt-5.5".into(), "test".into(), "test-session-id".into()); app.model_variant = Some("xhigh".into()); app.execution_mode_label = "rlm".into(); @@ -735,21 +735,6 @@ mod tests { "ok make the required changes and ship it".into(), )] .into(); - app.plan_dock = Some(PlanDockState { - title: "Plan".into(), - meta: None, - items: vec![ - PlanDockItem { - text: "Check git state vs main".into(), - status: PlanDockItemStatus::Done, - }, - PlanDockItem { - text: "Monitor GitHub Actions and fix failures".into(), - status: PlanDockItemStatus::Active, - }, - ], - }); - let lines = lash_tui::render_snapshot(80, 16, |frame| draw(frame, &mut app)) .visible_lines_trimmed(); let row = |needle: &str| { @@ -767,19 +752,11 @@ mod tests { lines[0] ); - // The user message and the Plan header are separated by a blank gutter. let message = row("ok make the required changes"); - let plan = row("Plan"); - assert_eq!(plan, message + 2, "expected one gutter row before Plan"); - assert!(lines[message + 1].trim().is_empty(), "gutter row not blank"); - - // The last checklist item hugs the input: the row directly below it is - // the input's top rule (full-width ─), not empty space. - let last_item = row("Monitor GitHub Actions"); assert!( - lines[last_item + 1].starts_with("───"), - "checklist should sit directly above the input rule, got {:?}", - lines[last_item + 1] + lines[message + 1].starts_with("───"), + "message should sit directly above the input rule, got {:?}", + lines[message + 1] ); } diff --git a/crates/lash-cli/src/render/sections/docks.rs b/crates/lash-cli/src/render/sections/docks.rs index 6f134b2..8fd5bea 100644 --- a/crates/lash-cli/src/render/sections/docks.rs +++ b/crates/lash-cli/src/render/sections/docks.rs @@ -1,68 +1,3 @@ -/// Render the plan checklist as trailing transcript content: one blank -/// gutter row separating it from the prior block, a dim `Plan` header, -/// then one row per step. No scribe rule (that was dock-chrome — inline -/// the checklist just reads like a regular block). Returns `None` when no -/// plan is active. The row count here must match -/// [`plan_dock_trailing_height`](crate::render::plan_dock_trailing_height). -pub fn plan_dock_lines_snapshot(app: &App, _frame_width: u16) -> Option>> { - use crate::app::PlanDockItemStatus; - - let plan = app.plan_dock.as_ref()?; - if plan.is_empty() { - return None; - } - - let mut lines: Vec> = Vec::new(); - - // Blank gutter so the checklist breathes away from the message or - // assistant trace it trails, then the dim header. - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - " Plan", - theme::text_faint_style().add_modifier(Modifier::Dim), - )])); - - // ── items - for item in &plan.items { - let (glyph, glyph_style, text_style) = match item.status { - PlanDockItemStatus::Done => ( - "✓ ", - Style::default() - .fg(theme::state_ok()) - .add_modifier(Modifier::Bold), - Style::default() - .fg(theme::text_faint()) - .add_modifier(Modifier::Dim), - ), - PlanDockItemStatus::Active => ( - // `▶` — the `■` glyph is reserved for the live - // assistant-text marker. Using a distinct arrow here - // keeps "current plan step" visually orthogonal from - // "model is speaking right now." - "▶ ", - Style::default() - .fg(theme::brand()) - .add_modifier(Modifier::Bold), - Style::default() - .fg(theme::text_primary()) - .add_modifier(Modifier::Bold), - ), - PlanDockItemStatus::Pending => ( - "□ ", - Style::default().fg(theme::text_subtle()), - Style::default().fg(theme::text_muted()), - ), - }; - lines.push(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled(glyph, glyph_style), - Span::styled(item.text.clone(), text_style), - ])); - } - - Some(lines) -} - pub fn process_lines_snapshot(app: &App, _frame_width: u16) -> Option>> { if app.processes.is_empty() { return None; diff --git a/crates/lash-cli/src/render/sections/layout.rs b/crates/lash-cli/src/render/sections/layout.rs index 610ee15..a9f8d14 100644 --- a/crates/lash-cli/src/render/sections/layout.rs +++ b/crates/lash-cli/src/render/sections/layout.rs @@ -28,17 +28,6 @@ fn queue_preview_height(app: &App, frame_width: u16) -> u16 { queue_preview_lines_snapshot(app, frame_width).len() as u16 } -/// Rows the plan checklist contributes to the trailing end of the -/// transcript: 1 blank gutter + 1 `Plan` header + N items. Must stay in -/// lockstep with [`plan_dock_lines_snapshot`](crate::render::plan_dock_lines_snapshot), -/// since the bottom-anchor pad derives the underflow from this total. -pub fn plan_dock_trailing_height(app: &App) -> usize { - match app.plan_dock.as_ref() { - Some(plan) if !plan.is_empty() => 2 + plan.items.len(), - _ => 0, - } -} - pub fn process_dock_height(app: &App, frame_width: u16) -> u16 { process_lines_snapshot(app, frame_width) .map(|lines| lines.len() as u16) diff --git a/crates/lash-cli/src/render/tests.rs b/crates/lash-cli/src/render/tests.rs index fe2c004..9840c23 100644 --- a/crates/lash-cli/src/render/tests.rs +++ b/crates/lash-cli/src/render/tests.rs @@ -241,8 +241,8 @@ fn prompt_question_wraps_long_path_cleanly() { let (response_tx, _response_rx) = mpsc::channel(); let prompt = PromptState { request: PromptRequest::single( - "Plan .lash/plans/15d5a2bd-841d-4729-8968-ae7874385e16.md is ready. Exit plan mode now?", - vec!["Exit plan mode".into(), "Keep planning".into()], + "Review workspace/15d5a2bd-841d-4729-8968-ae7874385e16.md is ready. Exit review mode now?", + vec!["Exit review mode".into(), "Keep reviewing".into()], ) .with_optional_note(), focus: crate::overlay::PromptFocus::Options, @@ -264,9 +264,9 @@ fn prompt_question_wraps_long_path_cleanly() { }) .collect::>(); - assert_eq!(rendered[0], "Plan .lash/plans/15d5a2bd-841d-4729-89"); - assert_eq!(rendered[1], "68-ae7874385e16.md is ready. Exit plan"); - assert_eq!(rendered[2], " mode now?"); + assert_eq!(rendered[0], "Review workspace/15d5a2bd-841d-4729-89"); + assert_eq!(rendered[1], "68-ae7874385e16.md is ready. Exit revi"); + assert_eq!(rendered[2], "ew mode now?"); assert_eq!(rendered[3], ""); assert!(rendered.iter().any(|line| line.contains("Choices"))); } @@ -276,11 +276,11 @@ fn prompt_panel_renders_before_question_and_choices() { let (response_tx, _response_rx) = mpsc::channel(); let prompt = PromptState { request: PromptRequest::single( - "Exit plan mode?", - vec!["Exit plan mode".into(), "Keep planning".into()], + "Review mode?", + vec!["Exit review mode".into(), "Keep reviewing".into()], ) .with_optional_note() - .with_markdown_panel("PLAN", "# Plan\n\n## Steps\n- First\n- Second"), + .with_markdown_panel("REVIEW", "# Review\n\n## Steps\n- First\n- Second"), focus: crate::overlay::PromptFocus::Options, cursor: 0, scroll_offset: 0, @@ -300,20 +300,20 @@ fn prompt_panel_renders_before_question_and_choices() { }) .collect::>(); - let plan_idx = rendered + let review_idx = rendered .iter() - .position(|line| line.contains("PLAN")) - .expect("plan panel"); + .position(|line| line.contains("REVIEW")) + .expect("review panel"); let question_idx = rendered .iter() - .position(|line| line.contains("Exit plan mode?")) + .position(|line| line.contains("Review mode?")) .expect("question"); let choices_idx = rendered .iter() .position(|line| line.contains("Choices")) .expect("choices"); - assert!(plan_idx < question_idx); + assert!(review_idx < question_idx); assert!(question_idx < choices_idx); assert!(rendered.iter().any(|line| line.contains("First"))); assert!(rendered.iter().any(|line| line.contains("Second"))); @@ -324,8 +324,8 @@ fn prompt_panel_renders_before_question_and_choices() { fn prompt_panel_strips_redundant_h1_matching_panel_title() { let (response_tx, _response_rx) = mpsc::channel(); let prompt = PromptState { - request: PromptRequest::single("Exit plan mode?", vec!["Exit".into()]) - .with_markdown_panel("PLAN", "# Plan\n\n## Steps\n- First"), + request: PromptRequest::single("Review mode?", vec!["Exit".into()]) + .with_markdown_panel("REVIEW", "# Review\n\n## Steps\n- First"), focus: crate::overlay::PromptFocus::Options, cursor: 0, scroll_offset: 0, @@ -347,10 +347,10 @@ fn prompt_panel_strips_redundant_h1_matching_panel_title() { let panel_labels = rendered .iter() - .filter(|line| line.contains("PLAN")) + .filter(|line| line.contains("REVIEW")) .collect::>(); assert_eq!(panel_labels.len(), 1); - assert!(!rendered.iter().any(|line| line.trim() == "Plan")); + assert!(!rendered.iter().any(|line| line.trim() == "Review")); } #[test] @@ -475,18 +475,18 @@ fn activity_block_indents_snippet_preview_under_summary() { ActivityKind::GenericTool, "preview_text", Value::Null, - "preview crates/lash/src/plugin_builtin/plan_mode.rs:780-786", + "preview crates/lash/src/plugin_builtin/workflow.rs:780-786", ActivityStatus::Completed, Value::Null, 0, ) .with_artifact(Some(ActivityArtifact::SnippetPreview( SnippetPreviewArtifact { - title: Some("plan-modes blocked tool message".into()), - path: "crates/lash/src/plugin_builtin/plan_mode.rs".into(), + title: Some("workflow blocked tool message".into()), + path: "crates/lash/src/plugin_builtin/workflow.rs".into(), start_line: 780, end_line: 786, - content: "if ctx.tool_name != \"plan_exit\" {\n return Ok(());\n}".into(), + content: "if ctx.tool_name != \"workflow_complete\" {\n return Ok(());\n}".into(), render_mode: SnippetRenderMode::Code, language: Some("rs".into()), }, @@ -504,23 +504,23 @@ fn activity_block_indents_snippet_preview_under_summary() { .collect::>(); assert!(rendered.iter().any(|line| { - line.starts_with("• preview crates/lash/src/plugin_builtin/plan_mode.rs:780-786") + line.starts_with("• preview crates/lash/src/plugin_builtin/workflow.rs:780-786") })); assert!( rendered .iter() - .any(|line| line.starts_with(" plan-modes blocked tool message")) + .any(|line| line.starts_with(" workflow blocked tool message")) ); assert!( !rendered.iter().any(|line| { - line.starts_with(" File · crates/lash/src/plugin_builtin/plan_mode.rs:780-786") + line.starts_with(" File · crates/lash/src/plugin_builtin/workflow.rs:780-786") }), "metadata line should be suppressed when custom title is present" ); assert!( rendered .iter() - .any(|line| line.starts_with(" 780 │ if ctx.tool_name != \"plan_exit\" {")) + .any(|line| line.starts_with(" 780 │ if ctx.tool_name != \"workflow_complete\" {")) ); } @@ -865,10 +865,10 @@ fn live_tool_output_without_running_activity_renders_as_tail_block() { #[test] fn plugin_panel_renders_as_section_header_without_box() { let blocks = vec![UiTimelineItem::PluginPanel(crate::app::PluginPanelBlock { - plugin_id: "plan_mode".into(), + plugin_id: "test_plugin".into(), key: "panel".into(), - title: "PLAN".into(), - content: "Path: `.lash/plans/demo.md`".into(), + title: "DETAILS".into(), + content: "Path: `workspace/demo.md`".into(), })]; let rendered = render_block(&blocks, 0, 1, 72, 20) @@ -881,94 +881,16 @@ fn plugin_panel_renders_as_section_header_without_box() { }) .collect::>(); - assert!(rendered.iter().any(|line| line.contains("PLAN"))); + assert!(rendered.iter().any(|line| line.contains("DETAILS"))); assert!( rendered .iter() - .any(|line| line.contains("Path: .lash/plans/demo.md")) + .any(|line| line.contains("Path: workspace/demo.md")) ); assert!(!rendered.iter().any(|line| line.contains("┌"))); assert!(!rendered.iter().any(|line| line.contains("└"))); } -#[test] -fn plan_dock_renders_as_checklist_with_dim_plan_header() { - use crate::app::{App, PlanDockItem, PlanDockItemStatus, PlanDockState}; - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - app.plan_dock = Some(PlanDockState { - title: "PLAN".into(), - meta: None, - items: vec![ - PlanDockItem { - text: "already done".into(), - status: PlanDockItemStatus::Done, - }, - PlanDockItem { - text: "in flight".into(), - status: PlanDockItemStatus::Active, - }, - PlanDockItem { - text: "not yet".into(), - status: PlanDockItemStatus::Pending, - }, - ], - }); - - let lines = crate::render::plan_dock_lines_snapshot(&app, 80) - .expect("plan dock should render when items are present"); - let text: Vec = lines - .iter() - .map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) - .collect(); - - // Blank gutter + header row + 3 items = 5 rows. No scribe rule. - assert_eq!( - text.len(), - 5, - "expected 1 gutter + 1 header + 3 items, got {text:?}" - ); - assert!( - text[0].trim().is_empty(), - "row 0 should be the blank gutter" - ); - assert!(text[1].contains("Plan")); - assert!(text[2].contains("✓") && text[2].contains("already done")); - assert!(text[3].contains("▶") && text[3].contains("in flight")); - assert!(text[4].contains("□") && text[4].contains("not yet")); - assert!( - !text.iter().any(|line| line.contains("─")), - "no scribe rule expected, got {text:?}", - ); -} - -#[test] -fn plan_dock_trailing_height_includes_gutter_plus_items() { - use crate::app::{App, PlanDockItem, PlanDockItemStatus, PlanDockState}; - let mut app = App::new("test-model".into(), "test".into(), "test-session-id".into()); - assert_eq!(crate::render::plan_dock_trailing_height(&app), 0); - - app.plan_dock = Some(PlanDockState { - title: String::new(), - meta: None, - items: vec![ - PlanDockItem { - text: "a".into(), - status: PlanDockItemStatus::Pending, - }, - PlanDockItem { - text: "b".into(), - status: PlanDockItemStatus::Pending, - }, - ], - }); - assert_eq!(crate::render::plan_dock_trailing_height(&app), 4); -} - #[test] fn styled_snippet_chunk_highlights_code_tokens() { let spans = artifact::styled_snippet_chunk_for_test( @@ -1078,7 +1000,7 @@ fn snippet_preview_renders_markdown_list_snippet_without_literal_emphasis_marker fn snippet_preview_wraps_long_markdown_bullets_to_viewport_width() { let preview = SnippetPreviewArtifact { title: Some("Plan excerpt".into()), - path: ".lash/plans/demo.md".into(), + path: "workspace/demo.md".into(), start_line: 1, end_line: 4, content: "## Goal\n\n- Complete a full spring-cleaning pass in two phases: first remove dead or stale things, then simplify what remains.".into(), diff --git a/crates/lash-cli/src/startup/plugins.rs b/crates/lash-cli/src/startup/plugins.rs index 6a407a9..7a94808 100644 --- a/crates/lash-cli/src/startup/plugins.rs +++ b/crates/lash-cli/src/startup/plugins.rs @@ -77,11 +77,6 @@ pub(super) fn plugin_factories_for_surface(input: PluginFactorySurfaceInput<'_>) ); } 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(crate::plan_plugin::UpdatePlanPluginFactory)); } plugin_stack.push(Arc::new(lash_autoresearch::AutoresearchPluginFactory)); if execution_mode.is_rlm() { @@ -110,16 +105,10 @@ fn cli_child_tool_access() -> SessionToolAccess { } fn cli_child_hidden_tools() -> BTreeSet { - [ - "ask", - "showcase", - "request_user_input", - "plan_exit", - "update_plan", - ] - .into_iter() - .map(ToOwned::to_owned) - .collect() + ["ask", "showcase", "request_user_input", "plan_exit"] + .into_iter() + .map(ToOwned::to_owned) + .collect() } fn autonomous_tool_allowed(name: &str) -> bool { @@ -334,7 +323,6 @@ mod tests { assert!(hidden.contains("ask")); assert!(hidden.contains("plan_exit")); - assert!(hidden.contains("update_plan")); assert!(hidden.contains("showcase")); assert!(hidden.contains("request_user_input")); } diff --git a/crates/lash-cli/src/ui_perf/projection_cases.rs b/crates/lash-cli/src/ui_perf/projection_cases.rs index 14d8ccb..7f9499c 100644 --- a/crates/lash-cli/src/ui_perf/projection_cases.rs +++ b/crates/lash-cli/src/ui_perf/projection_cases.rs @@ -411,27 +411,13 @@ fn live_activity_event(index: usize) -> TurnEvent { graph_key: None, parent_call_id: None, }, - 10 => TurnEvent::ToolCallCompleted { - call_id: Some(format!("plan-{index}")), - name: "update_plan".to_string(), - args: json!({ - "plan": [ - {"step": "Inspect projection", "status": "completed"}, - {"step": "Patch profiler", "status": "in_progress"} - ] - }), - output: ToolCallOutput::success(json!({ "ok": true })), - duration_ms: 1, - graph_key: None, - parent_call_id: None, - }, - 11 => TurnEvent::RetryStatus { + 10 => TurnEvent::RetryStatus { wait_seconds: 1, attempt: 2, max_attempts: 3, reason: "provider backpressure while profiling".to_string(), }, - 12 => TurnEvent::FinalValue { + 11 => TurnEvent::FinalValue { value: json!({ "status": "done", "index": index }), }, _ => TurnEvent::ToolCallCompleted { diff --git a/crates/lash-tui-extensions/src/lib.rs b/crates/lash-tui-extensions/src/lib.rs index 9b13b57..762a952 100644 --- a/crates/lash-tui-extensions/src/lib.rs +++ b/crates/lash-tui-extensions/src/lib.rs @@ -5,7 +5,6 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use lash::TurnEvent; -use lash::plugins::PluginRuntimeEvent; use lash_tui::{Frame, InputEvent, TermCapabilities}; pub use surface::{ @@ -265,10 +264,8 @@ pub struct TuiExtensions { } impl TuiExtensions { - pub fn with_builtins(mut extra: Vec>) -> Result { - let mut extensions: Vec> = vec![Arc::new(PlanModeTuiExtension)]; - extensions.append(&mut extra); - Self::new(extensions) + pub fn with_builtins(extra: Vec>) -> Result { + Self::new(extra) } pub fn new(extensions: Vec>) -> Result { @@ -606,162 +603,6 @@ fn command_matches(name: &str, spec: SlashCommandSpec) -> bool { name == spec.name || spec.aliases.contains(&name) } -#[derive(Clone, Debug, PartialEq, Eq)] -struct PlanModeStatus { - enabled: bool, - plan_path: Option, -} - -fn plan_mode_effects(status: &PlanModeStatus) -> Vec { - let key = global_surface_id("plan_mode", "mode"); - let mut effects = if status.enabled { - vec![TuiHostEffect::UpsertModeIndicator { - key, - label: "plan".to_string(), - }] - } else { - vec![TuiHostEffect::ClearModeIndicator { key }] - }; - let panel_key = "panel".to_string(); - if status.enabled { - if let Some(path) = status.plan_path.as_deref() { - effects.push(TuiHostEffect::UpsertPanel { - plugin_id: "plan_mode".to_string(), - key: panel_key, - title: "PLAN".to_string(), - content: format!("Path: `{path}`"), - }); - } else { - effects.push(TuiHostEffect::ClearPanel { - plugin_id: "plan_mode".to_string(), - key: panel_key, - }); - } - } else { - effects.push(TuiHostEffect::ClearPanel { - plugin_id: "plan_mode".to_string(), - key: panel_key, - }); - } - effects -} - -fn plan_mode_status_from_payload(payload: &serde_json::Value) -> Option { - Some(PlanModeStatus { - enabled: payload.get("enabled")?.as_bool()?, - plan_path: payload - .get("plan_path") - .and_then(|value| value.as_str()) - .map(str::to_string), - }) -} - -struct PlanModeTuiExtension; - -const PLAN_MODE_COMMANDS: &[SlashCommandSpec] = &[SlashCommandSpec { - name: "/plan", - aliases: &[], - usage: "/plan", - description: "Open file-backed planning mode", - argument_hint: None, - argument_options: &[], - takes_argument: false, - allow_while_running: true, - action: "toggle", -}]; - -const PLAN_MODE_SHORTCUTS: &[ShortcutSpec] = &[ShortcutSpec { - chord: KeyChord::SHIFT_TAB, - description: "Open file-backed planning mode", - action: "toggle", -}]; - -#[async_trait] -impl TuiExtension for PlanModeTuiExtension { - fn id(&self) -> &'static str { - "plan_mode_ui" - } - - fn commands(&self) -> &'static [SlashCommandSpec] { - PLAN_MODE_COMMANDS - } - - fn shortcuts(&self) -> &'static [ShortcutSpec] { - PLAN_MODE_SHORTCUTS - } - - async fn invoke_action( - &self, - action: &str, - _arg: Option<&str>, - _ctx: TuiExtensionContext, - ) -> Result, String> { - match action { - "toggle" => Ok(vec![TuiHostEffect::RunPluginCommand { - name: "plan_mode.toggle".to_string(), - args: serde_json::json!({}), - }]), - other => Err(format!("unknown plan-mode UI action `{other}`")), - } - } - - fn handle_turn_event(&self, event: &TurnEvent) -> Vec { - if let TurnEvent::PluginRuntime { plugin_id, event } = event - && plugin_id == "plan_mode" - && let PluginRuntimeEvent::Custom { name, payload } = event - && name == "plan_mode.state" - && let Some(status) = plan_mode_status_from_payload(payload) - { - return plan_mode_effects(&status); - } - - let TurnEvent::ToolCallCompleted { name, output, .. } = event else { - return Vec::new(); - }; - if name != "plan_exit" || !output.is_success() { - return Vec::new(); - } - let result = output.value_for_projection(); - let approved = result - .get("approved") - .and_then(|value| value.as_bool()) - .unwrap_or(false); - if approved { - let mut effects = vec![ - TuiHostEffect::ClearModeIndicator { - key: global_surface_id("plan_mode", "mode"), - }, - TuiHostEffect::ClearPanel { - plugin_id: "plan_mode".to_string(), - key: "panel".to_string(), - }, - ]; - if result - .get("execution_mode") - .and_then(|value| value.as_str()) - == Some("current_session") - && let Some(input) = result - .get("next_turn_input") - .and_then(|value| value.as_str()) - .filter(|value| !value.trim().is_empty()) - { - let display_text = result - .get("confirmation_display") - .and_then(|value| value.as_str()) - .filter(|value| !value.trim().is_empty()) - .unwrap_or(input); - effects.push(TuiHostEffect::QueuePreparedTurn { - display_text: display_text.to_string(), - effective_text: input.to_string(), - }); - } - effects - } else { - Vec::new() - } - } -} - #[cfg(test)] mod tests { use std::collections::BTreeSet; @@ -769,165 +610,6 @@ mod tests { use super::*; use lash_tui::{Rect, Style}; - use serde_json::json; - - fn completed_tool_event(name: &str, result: serde_json::Value) -> TurnEvent { - TurnEvent::ToolCallCompleted { - call_id: None, - name: name.to_string(), - args: json!({}), - output: lash::tools::ToolCallOutput::success(result), - duration_ms: 12, - graph_key: None, - parent_call_id: None, - } - } - - #[test] - fn builtin_extensions_register_plan_command_and_shortcut() { - let extensions = TuiExtensions::builtin().expect("builtin extensions"); - - assert!(extensions.parse_command("/plan").is_some()); - assert!(extensions.shortcut_for(KeyChord::SHIFT_TAB).is_some()); - assert_eq!( - extensions.completions("/pl"), - vec![( - "/plan".to_string(), - "Open file-backed planning mode".to_string(), - )] - ); - } - - #[test] - fn plan_mode_effects_show_panel_when_enabled() { - assert_eq!( - plan_mode_effects(&PlanModeStatus { - enabled: true, - plan_path: Some(".lash/plans/root.md".to_string()), - }), - vec![ - TuiHostEffect::UpsertModeIndicator { - key: "plan_mode:mode".to_string(), - label: "plan".to_string(), - }, - TuiHostEffect::UpsertPanel { - plugin_id: "plan_mode".to_string(), - key: "panel".to_string(), - title: "PLAN".to_string(), - content: "Path: `.lash/plans/root.md`".to_string(), - } - ] - ); - } - - #[test] - fn plan_mode_effects_clear_panel_when_disabled() { - assert_eq!( - plan_mode_effects(&PlanModeStatus { - enabled: false, - plan_path: Some(".lash/plans/root.md".to_string()), - }), - vec![ - TuiHostEffect::ClearModeIndicator { - key: "plan_mode:mode".to_string(), - }, - TuiHostEffect::ClearPanel { - plugin_id: "plan_mode".to_string(), - key: "panel".to_string(), - } - ] - ); - } - - #[test] - fn duplicate_commands_are_rejected() { - struct Duplicate; - - #[async_trait] - impl TuiExtension for Duplicate { - fn id(&self) -> &'static str { - "duplicate" - } - - fn commands(&self) -> &'static [SlashCommandSpec] { - PLAN_MODE_COMMANDS - } - - async fn invoke_action( - &self, - _action: &str, - _arg: Option<&str>, - _ctx: TuiExtensionContext, - ) -> Result, String> { - Ok(Vec::new()) - } - } - - let err = TuiExtensions::new(vec![Arc::new(PlanModeTuiExtension), Arc::new(Duplicate)]) - .err() - .expect("duplicate commands should fail"); - assert!(err.contains("/plan")); - } - - #[test] - fn plan_exit_event_queues_current_session_follow_up() { - let extensions = TuiExtensions::builtin().expect("builtin extensions"); - - let effects = extensions.effects_for_turn_event(&completed_tool_event( - "plan_exit", - json!({ - "approved": true, - "execution_mode": "current_session", - "confirmation_display": "Start implementing now", - "next_turn_input": "Execute the approved plan." - }), - )); - - assert_eq!( - effects, - vec![ - TuiHostEffect::ClearModeIndicator { - key: "plan_mode:mode".to_string() - }, - TuiHostEffect::ClearPanel { - plugin_id: "plan_mode".to_string(), - key: "panel".to_string() - }, - TuiHostEffect::QueuePreparedTurn { - display_text: "Start implementing now".to_string(), - effective_text: "Execute the approved plan.".to_string() - } - ] - ); - } - - #[test] - fn plan_exit_event_clears_ui_for_fresh_context_frame_switch() { - let extensions = TuiExtensions::builtin().expect("builtin extensions"); - - let effects = extensions.effects_for_turn_event(&completed_tool_event( - "plan_exit", - json!({ - "approved": true, - "execution_mode": "fresh_context", - "session_id": "new-plan-session", - }), - )); - - assert_eq!( - effects, - vec![ - TuiHostEffect::ClearModeIndicator { - key: "plan_mode:mode".to_string() - }, - TuiHostEffect::ClearPanel { - plugin_id: "plan_mode".to_string(), - key: "panel".to_string() - }, - ] - ); - } - #[test] fn command_tokens_and_shortcuts_are_unique() { let extensions = TuiExtensions::builtin().expect("builtin extensions"); diff --git a/runbooks/process-dock-lifecycle/runbook.md b/runbooks/process-dock-lifecycle/runbook.md index 1f8d55a..5d4b28b 100644 --- a/runbooks/process-dock-lifecycle/runbook.md +++ b/runbooks/process-dock-lifecycle/runbook.md @@ -130,8 +130,8 @@ screen 18 Gate: no `Background` dock, and `Tab` opens no process overview (the dock-focus binding — `docs/index.html`: "With an empty prompt, cycle focus through the dock of background -processes" — falls through when the dock is empty; note that `Shift+Tab` here falls through -to the plan-mode toggle, further evidence there is no dock to cycle). The process **cancel** +processes" — falls through when the dock is empty; `Shift+Tab` also falls through when +there is no dock to cycle). The process **cancel** (`Delete`) and **overview** (`Enter`) rungs cannot be exercised — no process exists to focus. Record this as the harness gap. Then `lash-exit 10`. From 75a244c262ac63f10d4acd4edccc21d90326e096 Mon Sep 17 00:00:00 2001 From: Samuel Galanakis Date: Tue, 8 Sep 2026 11:36:41 +0200 Subject: [PATCH 6/7] Correct operator runbook gates at the pinned runtime Preserve settled rows across differential renders, verify incarnation-scoped process lifecycle and retention, clarify resume aliases, and stop explicitly when frozen quickstart prerequisites are absent. Release-Notes: Correct operator runbook gates and document unavailable quickstart prerequisites. --- runbooks/process-dock-lifecycle/runbook.md | 79 ++++++++++++++-------- runbooks/quickstart-walkthrough/runbook.md | 12 +++- runbooks/resume-picker/runbook.md | 12 +++- runbooks/turn-flow-and-queueing/runbook.md | 20 +++--- 4 files changed, 83 insertions(+), 40 deletions(-) diff --git a/runbooks/process-dock-lifecycle/runbook.md b/runbooks/process-dock-lifecycle/runbook.md index 5d4b28b..f560bf0 100644 --- a/runbooks/process-dock-lifecycle/runbook.md +++ b/runbooks/process-dock-lifecycle/runbook.md @@ -10,7 +10,7 @@ reflects the session's durable process registry (`session.processes().list()`). A subagent spawn is a durable Runtime Process and therefore renders in two places: inline tool activity (`◆ spawn subagent · …`, footer `Running tool · spawn_agent`) and a transient dock -row (`Background`, `◆ running · subagent · spawn`). Its durable record in +row (`Background`, `◆ running · subagent · spawn` or `◆ success · subagent · spawn`). Its durable record in `store/processes.db` moves from `process.first_started` to `process.completed`, carries an observer edge to the parent session, and the settled dock row is pruned after the short retention window. @@ -74,49 +74,74 @@ confirm the deterministic provider and idle prompt. `--lash-home $LH` so its process store can be checked immediately after settlement. ``` +clear type Does your subagent tool work key enter expect 15 Running tool · spawn_agent +expect 15 ◆ spawn subagent expect 15 Background -expect 15 ◆ running · subagent · spawn -clear +expect-re 15 ◆ (running|success) · subagent · spawn expect 45 ■ subagent-ok +expect 10 Idle screen 40 ``` -Gate: the spawn shows as `◆ spawn subagent · …`, the footer passes through `Running tool · -spawn_agent`, and the live process projection renders `Background` with `◆ running · -subagent · spawn`. Keep the `clear` after the running-tool gates so `■ subagent-ok` must -come from a fresh settled frame. In that settled `screen`, the transient `Background` row -is absent after its retention window. +Gate: the spawn shows as `◆ spawn subagent · …`, the footer passes through +`Running tool · spawn_agent`, and the process projection renders `Background` with +`◆ running · subagent · spawn` or `◆ success · subagent · spawn`. The dock polls a +bounded observer read roughly every 250 ms; this sub-second scenario may complete +before its first snapshot. A terminal success row is therefore valid admission +visibility; the mandatory SQLite lifecycle check below still proves first-start and +completion. Error/cancelled/abandoned rows do not satisfy the gate. + +Clear **before submission**, not between the dock and assistant gates: settlement can +precede the dock snapshot, and an unchanged settled row is not re-emitted on demand. +`■ subagent-ok` must be an assistant row, never the tool's quoted task or a preview. +The completed dock row is retained for 10 seconds; immediate absence is not expected. Before `lash-exit`, cross-check the fixed home's process store from a second shell: ``` sqlite3 -header -column "$LH/store/processes.db" " -SELECT process_id, identity_kind, identity_label, status +SELECT process_id, incarnation, identity_kind, identity_label, status FROM processes WHERE identity_kind = 'subagent' AND identity_label = 'spawn'; -SELECT sequence, event_type -FROM process_events -WHERE process_id = ( - SELECT process_id FROM processes - WHERE identity_kind = 'subagent' AND identity_label = 'spawn' -) -AND event_type IN ('process.first_started', 'process.completed') -ORDER BY sequence; -SELECT session_id, process_id -FROM process_observers -WHERE process_id = ( - SELECT process_id FROM processes - WHERE identity_kind = 'subagent' AND identity_label = 'spawn' -);" +SELECT e.sequence, e.event_type +FROM process_events e +JOIN processes p ON p.process_id = e.process_id + AND p.incarnation = e.process_incarnation +WHERE p.identity_kind = 'subagent' AND p.identity_label = 'spawn' +AND e.event_type IN ('process.first_started', 'process.completed') +ORDER BY e.sequence; +SELECT o.session_id, o.process_id, o.process_incarnation +FROM process_observers o +JOIN processes p ON p.process_id = o.process_id + AND p.incarnation = o.process_incarnation +WHERE p.identity_kind = 'subagent' AND p.identity_label = 'spawn';" ``` Gate: exactly one process row reports `subagent`, `spawn`, and `completed`; exactly two selected lifecycle rows appear in order, `process.first_started` then `process.completed`; -and exactly one observer row links that process id to the parent session. Then -`lash-exit 10`. +and exactly one observer row links that process id **and incarnation** to the parent +session (cross-check the root session in the fixed home's catalog). + +**Retention pruning.** Dispatch the SQLite cross-check promptly on settlement, then: + +``` +clear +expect 15 ❯ Message · / for commands +screen 40 +lash-exit 10 +``` + +With no further input, removing the dock after its 10-second retention window changes +the input layout and re-emits the editor row. Gate that repaint, then inspect the fresh +capture and final trace snapshot: no `Background` header or subagent dock row remains. +An unchanged idle placeholder is not a gate on its own: it must follow the previously +observed dock and its layout removal. Save a trace with render checkpoints (forward +`--debug-ui-trace-interval-ms 100`) to corroborate the visible dock and final absence. +A timeout or a dock still present is Abort/RCA. Never insert a retention sleep or accept +absence in an empty capture as proof. **Empty dock has nothing to focus.** Relaunch `--scenario standard-echo`. With an idle empty prompt and no processes, the dock-focus keys have no target: @@ -168,9 +193,9 @@ tokens — deliberate). | Item | Objective gate | Verdict | Notes | |------|----------------|---------|-------| -| Subagent renders on both surfaces | `◆ spawn subagent` + `Running tool · spawn_agent` + `Background` / `◆ running · subagent · spawn`, then post-`clear` `■ subagent-ok` | | | +| Subagent renders on both surfaces | `◆ spawn subagent` + `Running tool · spawn_agent` + `Background` / `◆ running · subagent · spawn` or `◆ success · subagent · spawn`, then `■ subagent-ok` from the pre-submission-cleared capture | | | | Durable subagent lifecycle | one `subagent / spawn / completed` process row; `process.first_started → process.completed`; one parent-session observer edge | | | -| Transient row is pruned | settled post-`clear` screen has no `Background` row | | | +| Transient row is pruned | after the 10-second retention window, layout repaint + fresh screen and final snapshot have no `Background` row | | | | 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 e2b7e17..aad7fc6 100644 --- a/runbooks/quickstart-walkthrough/runbook.md +++ b/runbooks/quickstart-walkthrough/runbook.md @@ -39,6 +39,16 @@ operator surface. That is a scope clarification to record, not a page defect. program's "one turn → one settled message" behavior; it is not a line-by-line execution of the Rust snippet. Say so. +## Frozen-documentation prerequisite + +The pinned Lash revision may intentionally omit `docs/quickstart.html` and +`scripts/lint_docs.py` (the documentation freeze began in `0d399a4dc`). Check their +presence at the exact workspace pin before attempting Phase 1. If either is absent, +record **Abort — documentation prerequisite unavailable**, with the pinned tree and +deletion commit as evidence. Do not substitute historical or deployed docs, skip the +lint gate, or award the structural/API score from a snippet compile alone. Resume this +runbook only when its page and checker are available at the tested pin. + ## Phase 0 — Pre-flight Per [../RULES.md](../RULES.md). In a Lash repository checkout, read @@ -71,7 +81,7 @@ scripts/lash-operator.py --provider test expect 20 Message · / for commands type hello from pty key enter -expect 25 test-provider echo: hello from pty +expect 25 ■ test-provider echo: hello from pty lash-exit 10 ``` diff --git a/runbooks/resume-picker/runbook.md b/runbooks/resume-picker/runbook.md index 1877c4c..94d9342 100644 --- a/runbooks/resume-picker/runbook.md +++ b/runbooks/resume-picker/runbook.md @@ -46,7 +46,7 @@ $LH` against a **clean** `$LH`, confirm the deterministic provider, gate the idl ``` type hello from pty key enter -expect 25 test-provider echo: hello from pty +expect 25 ■ test-provider echo: hello from pty wait 2 type /clear key enter @@ -115,8 +115,13 @@ 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**. 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 — +`/resume ` (the `name` shown by `/info`). First `/clear` and gate +`Started new session` so the rigor case must actually switch sessions and its confirmation +cannot be an unchanged row from the preceding id lookup. Clear the capture, submit the +name target, and gate **`Resumed: `**: the confirmation always prints the +resolved catalog id, not the input alias. Open `/info` and confirm both B's name and id. +Dismiss it with `Esc`, `clear`, and an action-driven editor-row gate before exit. If the +name lookup 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. Catalog-id resolution is the escape-hatch gate; name resolution is the rigor case. Legacy per-session database filename targets are intentionally @@ -133,6 +138,7 @@ type /clear key enter expect 10 Started new session wait 2 +clear type /clear key enter expect 10 Started new session diff --git a/runbooks/turn-flow-and-queueing/runbook.md b/runbooks/turn-flow-and-queueing/runbook.md index 9bf0500..2492680 100644 --- a/runbooks/turn-flow-and-queueing/runbook.md +++ b/runbooks/turn-flow-and-queueing/runbook.md @@ -52,7 +52,7 @@ Optionally add `--trace trace.json` so Phase 2 can cross-check the ingress ops. type hello from pty expect 5 hello from pty key enter -expect 25 test-provider echo: hello from pty +expect 25 ■ test-provider echo: hello from pty ``` Gates: the draft echoes as a user row `● hello from pty`; the footer walks @@ -69,7 +69,7 @@ active (blocked in `Thinking`): expect 20 Message · / for commands type gated initial prompt key enter -expect 20 gated initial prompt +expect 20 ● gated initial prompt expect 15 Thinking ``` @@ -79,7 +79,7 @@ expect 15 Thinking type inject this now key enter expect 10 ◆ Will send in this turn -expect 5 inject this now +expect 5 ↳ inject this now ``` **Next Full Turn (Tab, mid-turn):** @@ -88,7 +88,7 @@ expect 5 inject this now type hold for next turn key tab expect 10 ◇ Queued for next turn -expect 5 hold for next turn +expect 5 ↳ hold for next turn ``` Objective gate: both sections render above the input, `◆ Will send in this turn` carrying @@ -108,7 +108,7 @@ active window, then let the first turn commit and watch the queued draft dispatc expect 20 Message · / for commands type slow initial prompt key enter -expect 10 slow initial prompt +expect 10 ● slow initial prompt type hold for next turn key tab expect 8 ◇ Queued for next turn @@ -141,23 +141,25 @@ stalled: expect 20 Message · / for commands type slow initial prompt key enter -expect 10 slow initial prompt +expect 10 ● slow initial prompt expect 10 Thinking type generation fence follow-up key enter expect 8 ◆ Will send in this turn -expect 5 generation fence follow-up +expect 5 ↳ generation fence follow-up clear expect 25 ■ test-provider echo: slow initial prompt expect 20 ● slow initial prompt expect 20 ● generation fence follow-up -clear -expect 25 ■ test-provider echo: slow initial prompt expect 20 Idle screen 80 lash-exit 10 ``` +Keep the capture from the pre-settlement `clear` through the idle gate. The assistant +row may already be committed when both user-row gates pass; clearing again would demand +an unchanged row that the differential renderer has no reason to re-emit. + Gates: both `● slow initial prompt` and `● generation fence follow-up` are committed user rows, the assistant row `■ test-provider echo: slow initial prompt` commits, and the footer returns to the idle prompt. In the final `screen 80`, no `Superseded`, `turn failed`, or equivalent From 24c3eab0e1020e46d30fa9207767576d257e5719 Mon Sep 17 00:00:00 2001 From: Samuel Galanakis Date: Tue, 8 Sep 2026 11:52:48 +0200 Subject: [PATCH 7/7] Fix active turn targeting dispatch window Keep active-turn targeting unset after dispatch until Lash reports the durable turn identity. This prevents Esc cancellation and current-turn injection from addressing a phantom target during the dispatch window. Release-Notes: --- crates/lash-cli/src/interactive/runtime.rs | 121 ++++++++++++++++++++- 1 file changed, 120 insertions(+), 1 deletion(-) diff --git a/crates/lash-cli/src/interactive/runtime.rs b/crates/lash-cli/src/interactive/runtime.rs index 538a394..e37f453 100644 --- a/crates/lash-cli/src/interactive/runtime.rs +++ b/crates/lash-cli/src/interactive/runtime.rs @@ -167,7 +167,6 @@ 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, @@ -504,3 +503,123 @@ mod copy_tests { ); } } + +#[cfg(test)] +mod dispatch_tests { + use std::sync::Arc; + use std::time::Duration; + + use super::*; + use crate::app::App; + use crate::event::{AppEvent, AppEventPump}; + use crate::session_log::SessionLogger; + use crate::test_support::{EnvVarGuard, TempDirGuard, env_lock}; + + #[tokio::test] + async fn dispatch_window_has_no_active_turn_id_until_lash_observes_one() { + let _env_lock = env_lock().lock().await; + let temp = TempDirGuard::new("turn-id-dispatch"); + let _lash_home = EnvVarGuard::set("LASH_HOME", temp.path()); + + let provider = lash::testing::TestProvider::builder() + .kind("dispatch-window-turn-id") + .complete(|_request| async { + std::future::pending::<()>().await; + unreachable!("provider future is cancelled with the turn") + }) + .build() + .into_handle(); + let store_factory = Arc::new(lash::persistence::InMemorySessionStoreFactory::new()); + let core = lash::LashCore::standard_builder(lash::TurnBudget::Unbounded) + .commit_budget(crate::host_policy::commit_budget()) + .queued_work_batching(crate::host_policy::queued_work_batching()) + .effect_host(Arc::new( + lash::durability::NativeEffectHost::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::ModelSpec::builder("dispatch-window-turn-id") + .context_window_tokens(1_000_000) + .build() + .expect("test model"), + ) + .store_factory(store_factory) + .without_queued_work() + .build(lash::persistence::LeaseOwnerIdentity::opaque( + "lash-cli-test", + "dispatch-window-turn-id", + )) + .expect("test core"); + let session = core + .session("dispatch-window-turn-id") + .open() + .await + .expect("open test session"); + let mut runtime = Some(session); + let mut app = App::new( + "dispatch-window-turn-id".to_string(), + "dispatch-window-turn-id".to_string(), + "dispatch-window-turn-id".to_string(), + ); + let prepared_turn = PreparedTurn::new("dispatch window".to_string(), Vec::new()); + let mut logger = SessionLogger::new( + "dispatch-window-turn-id".to_string(), + "dispatch-window-turn-id".to_string(), + "dispatch-window-turn-id", + None, + ) + .expect("test session logger"); + let mut history = Vec::new(); + let mut runtime_return_rx = None; + let mut cancel_token = None; + let mut active_stream_id = 0; + let mut event_pump = AppEventPump::new(); + + send_user_message( + prepared_turn.clone(), + make_turn_input(&prepared_turn), + &mut app, + None, + &mut logger, + &mut runtime, + &mut history, + &mut runtime_return_rx, + &mut cancel_token, + &mut active_stream_id, + &event_pump.sender(), + ) + .await; + + assert_eq!(app.active_turn_id(), None); + + let observed = tokio::time::timeout(Duration::from_secs(2), event_pump.recv()) + .await + .expect("Lash reports the active turn") + .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, active_stream_id); + assert!(!turn_id.contains(':')); + app.set_active_turn_id(turn_id.clone()); + assert_eq!(app.active_turn_id(), Some(turn_id.as_str())); + + cancel_token + .take() + .expect("dispatch owns cancellation") + .cancel(); + let _ = tokio::time::timeout( + Duration::from_secs(2), + runtime_return_rx + .take() + .expect("dispatch owns return channel"), + ) + .await + .expect("cancelled dispatch settles"); + } +}