From cf9846583f9ee29f514ff34859b2b66e7189f114 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Wed, 26 Aug 2026 08:00:19 +0530 Subject: [PATCH 01/12] chore(deps): pin io-harness 0.69 and migrate the two breaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.67 -> 0.69, which is the newest published version. Two breaking changes reach this crate and both land in tests rather than src: * `EventKind::Mcp` gained `tools: Option` in 0.68.0 — how many tools a server offered, set on the event announcing it reaching the run and `None` on every other form. Every construction of the variant names it now, with `Some(n)` on the announcement and `None` on a call. * `SteerInbox::pending` returns `Steering` instead of `(Vec, bool)` in 0.69.0, so the third thing an operator can send — a fold — did not have to grow the tuple. The interrupt test binds the struct, and asserts that an interrupt is neither a message nor a fold. Nothing else moves: no dependency was added by either harness release, the feature list is untouched, and the suite is unmoved at 61 binaries / 792 passed / 0 failed / 24 ignored. --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- tests/glyphs.rs | 1 + tests/interrupt.rs | 16 +++++++++++++--- tests/servers.rs | 13 +++++++++++-- tests/status.rs | 4 ++++ tests/triage.rs | 1 + 7 files changed, 33 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f184c99..f9c123b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1053,9 +1053,9 @@ dependencies = [ [[package]] name = "io-harness" -version = "0.67.0" +version = "0.69.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bea823cc99e5c43894f0c260c25a2c8666882878bd534538d169806d6527a238" +checksum = "28c9b7027129f38c071489894f9e9760b7b027a5f5025d3af81944da258ce9df" dependencies = [ "base64 0.23.1", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index 4dff238..47f016a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,7 @@ path = "src/lib.rs" # consequence of enabling it. That is a capability change arriving inside an # interface release and it is stated in the README and the CHANGELOG rather than # left to be discovered. It is governed by the same policy as any other read. -io-harness = { version = "0.67", features = ["media", "browser"] } +io-harness = { version = "0.69", features = ["media", "browser"] } # ratatui stays at 0.29 and crossterm at 0.28 because `tui-textarea` 0.7 — the # composer — is built against exactly that pair. The 0.30 line has forks of the # textarea widget rather than a release of it, and taking one would mean depending diff --git a/tests/glyphs.rs b/tests/glyphs.rs index e9a9f0c..9dfa35d 100644 --- a/tests/glyphs.rs +++ b/tests/glyphs.rs @@ -446,6 +446,7 @@ fn every_event_this_release_renders_draws_in_ascii() { tool: Some("search".into()), ok: Some(true), millis: Some(42), + tools: None, }, // The plan block, which 0.7.0 added and which this list did not have. Two // items on purpose: one short enough to draw whole, and one longer than diff --git a/tests/interrupt.rs b/tests/interrupt.rs index dc5ac7a..1112c2b 100644 --- a/tests/interrupt.rs +++ b/tests/interrupt.rs @@ -62,9 +62,19 @@ fn f6_ctrl_c_during_a_turn_interrupts_it_and_keeps_the_partial_output() { assert_eq!(app.key(control('c')), Command::Interrupt); steer.interrupt().expect("the turn is still listening"); - let (messages, interrupted) = inbox.pending(); - assert!(interrupted, "Steer::interrupt never reached the inbox"); - assert!(messages.is_empty(), "an interrupt is not a message"); + // io-harness 0.69.0 replaced the `(Vec, bool)` tuple with `Steering`, + // which is `#[non_exhaustive]` so the fourth thing an operator can send costs + // this caller nothing. + let steering = inbox.pending(); + assert!( + steering.interrupted, + "Steer::interrupt never reached the inbox" + ); + assert!( + steering.messages.is_empty(), + "an interrupt is not a message" + ); + assert!(!steering.fold, "an interrupt is not a fold"); // The turn ends. The partial output is committed rather than lost with it. app.finished(); diff --git a/tests/servers.rs b/tests/servers.rs index 3b982d5..2e4b22c 100644 --- a/tests/servers.rs +++ b/tests/servers.rs @@ -22,23 +22,32 @@ command = \"mcp-search\" .expect("the fixture parses") } -/// The server reaching the run: an `Mcp` event with no `tool`. +/// The server reaching the run: an `Mcp` event with no `tool`. Since io-harness +/// 0.68.0 that event states how many tools the server offered, which is the one +/// form carrying the count. fn reached(server: &str) -> EventKind { + reached_offering(server, 3) +} + +/// The same event with the offered count named. +fn reached_offering(server: &str, tools: u32) -> EventKind { EventKind::Mcp { server: server.into(), tool: None, ok: None, millis: None, + tools: Some(tools), } } -/// One call, and whether it worked. +/// One call, and whether it worked. A call carries no count. fn called(server: &str, tool: &str, ok: Option) -> EventKind { EventKind::Mcp { server: server.into(), tool: Some(tool.into()), ok, millis: Some(12), + tools: None, } } diff --git a/tests/status.rs b/tests/status.rs index c4d5696..72d8fe9 100644 --- a/tests/status.rs +++ b/tests/status.rs @@ -797,6 +797,7 @@ fn f6_a_server_that_answered_is_named_with_the_tools_it_offered() { tool: None, ok: None, millis: None, + tools: Some(2), }), Duration::ZERO, ); @@ -808,6 +809,7 @@ fn f6_a_server_that_answered_is_named_with_the_tools_it_offered() { tool: Some(tool.into()), ok: Some(true), millis: Some(12), + tools: None, }), Duration::ZERO, ); @@ -1458,6 +1460,7 @@ fn reported() -> App { tool: None, ok: None, millis: None, + tools: Some(1), }), Duration::ZERO, ); @@ -1467,6 +1470,7 @@ fn reported() -> App { tool: Some("search".into()), ok: Some(true), millis: Some(12), + tools: None, }), Duration::ZERO, ); diff --git a/tests/triage.rs b/tests/triage.rs index 239bfd1..7322619 100644 --- a/tests/triage.rs +++ b/tests/triage.rs @@ -178,6 +178,7 @@ fn a_status_or_silent_kind_commits_nothing() { tool: None, ok: None, millis: None, + tools: Some(4), }, EventKind::HandlePolled { handle: 3, From 6d98e370f6fafa35093dd4af0eb6453be5c13ced Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Wed, 26 Aug 2026 08:26:05 +0530 Subject: [PATCH 02/12] feat(mcp): say how many tools a server offered, beside how many were used MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit io-harness 0.68.0 put the count on the wire: `EventKind::Mcp` carries `tools: Option`, set only on the event announcing a server reaching the run, over the server's whole listed catalogue, and `None` on every other form. That closes the deferral 0.16.0 recorded as US-IO-CLI-0.16.0-I01. Offered and asked-for are two different questions and are now two different numbers. `Some(0)` is a server that stated it offers nothing; `None` is an event with nothing to say about the count. Collapsing them would make a server that has only ever answered CALLS — every one of whose events carries `None` — report offering no tools while visibly using them, so a count is assigned only from a `Some` and a later `None` can neither write a zero nor erase one that was heard. The module doc that said the fact could not be obtained is corrected in the same commit that obtains it: a doc comment asserting a property is a claim the next reader trusts. Also fixes the one site 0.16.0's rename missed. `/status` said "offering N tools" over a number that has counted CALLS since 0.10.0, while the status line itself has said `mcp N/M calls` since. It says "answering N calls" now — two different numbers under one word is worse than either alone, and doubly so now that `/mcp` draws a real offered count beside it. --- src/servers.rs | 78 +++++++++++++++++++------- src/status.rs | 7 ++- tests/servers.rs | 141 +++++++++++++++++++++++++++++++++++++++++++++-- tests/status.rs | 7 ++- 4 files changed, 207 insertions(+), 26 deletions(-) diff --git a/src/servers.rs b/src/servers.rs index 4b87751..b0ae9e6 100644 --- a/src/servers.rs +++ b/src/servers.rs @@ -15,17 +15,26 @@ //! start, and drawing it as a failure would tell an operator their configuration //! is broken at the exact moment it is most likely to be fine. //! -//! # What the count is, and what it is not +//! # Two counts, and they are not the same question //! -//! `EventKind::Mcp` carries `server`, `tool`, `ok` and `millis` and **no count of -//! the tools a server offers** — and `io_harness::mcp` exposes no catalogue -//! accessor, so there is no other channel. What this module can count is the -//! number of DISTINCT TOOLS the session has asked a server for, which is a lower -//! bound on what it offers and is never larger than the truth. +//! **Offered** is how many tools a server announced. It arrives on `EventKind::Mcp` +//! as `tools: Option`, added by io-harness 0.68.0 and set **only** on the +//! event announcing a server reaching the run — over the server's full listed +//! catalogue. Every other form of the event, each `discovered` and every call, +//! carries `None`. Until 0.68.0 the fact was not on the wire at all and this +//! module said so; that sentence is now false, and the field it said did not +//! exist is what closes `US-IO-CLI-0.16.0-I01` / `US-IO-HARNESS-0.68.0-I01`. //! -//! The panel says so. The true count is io-cli 0.17.0's, behind the io-harness -//! release that puts it on the event — recorded as `US-IO-CLI-0.16.0-I01` here -//! and `US-IO-HARNESS-0.68.0-I01` there. +//! **`Some(0)` and `None` are different facts and this module must not collapse +//! them.** `Some(0)` is a server that stated it offers nothing; `None` is an +//! event with nothing to say about the count. Reading a missing count as zero +//! would make a server that has only ever answered CALLS — every one of whose +//! events carries `None` — report offering no tools while visibly using them. +//! +//! **Asked for** is the number of DISTINCT TOOLS this session has called, which +//! this module still counts itself because no event states it. It is a lower +//! bound on what is offered, and the two numbers are drawn as two numbers: one +//! replacing the other would answer a question nobody asked. //! //! # Two verbs this panel does not offer //! @@ -49,9 +58,14 @@ use crate::configure::Decided; /// What this session has seen of one configured server. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Reached { - /// It answered. The count is how many **distinct tools it has been asked - /// for**, which is a lower bound on what it offers — see the module docs. - Answered { tools: usize }, + /// It answered. + /// + /// `tools` is how many **distinct tools it has been asked for**. `offered` + /// is how many it **announced**, when an event said so — `Some(0)` is a + /// server that offers nothing and `None` is a server whose count this + /// session never saw, which is the state of one that has answered calls + /// without the announcing event ever being folded in. See the module docs. + Answered { tools: usize, offered: Option }, /// A call to it failed, and this is the last one that did. Failed { tool: String }, /// Nothing has been heard from it this session. @@ -94,8 +108,9 @@ pub struct Server { /// the other — the pair has no server names in it at all. #[derive(Debug, Clone, Default)] pub struct Observed { - /// Distinct tool names seen per server, and the last failure if there was one. - seen: BTreeMap, Option)>, + /// Distinct tool names seen per server, the last failure if there was one, + /// and the offered count if an event ever stated it. + seen: BTreeMap, Option, Option)>, } impl Observed { @@ -104,16 +119,26 @@ impl Observed { /// `EventKind::Mcp` means two things and the difference is whether `tool` is /// present: with none it is the server itself reaching the run, and with one /// it is a call. Both mark the server as reached; only the second can name a - /// tool or a failure. + /// tool or a failure, and only the first states an offered count. pub fn event(&mut self, kind: &EventKind) { let EventKind::Mcp { - server, tool, ok, .. + server, + tool, + ok, + tools, + .. } = kind else { return; }; let entry = self.seen.entry(server.clone()).or_default(); + // Only ever ASSIGNED from a `Some`. A `None` is an event with nothing to + // say about the count, not a statement that there are none — so it must + // neither write a zero nor erase a count an earlier event stated. + if let Some(offered) = tools { + entry.2 = Some(*offered); + } if let Some(tool) = tool { entry.0.insert(tool.clone()); // `ok` is an `Option`: `Some(false)` is a failure, and `None` @@ -138,9 +163,12 @@ impl Observed { pub fn of(&self, id: &str) -> Reached { match self.seen.get(id) { None => Reached::NotYet, - Some((tools, failure)) => match failure { + Some((tools, failure, offered)) => match failure { Some(tool) => Reached::Failed { tool: tool.clone() }, - None => Reached::Answered { tools: tools.len() }, + None => Reached::Answered { + tools: tools.len(), + offered: *offered, + }, }, } } @@ -195,7 +223,19 @@ pub fn rows(servers: &[Server]) -> Vec { .iter() .map(|server| { let state = match &server.state { - Reached::Answered { tools } => format!("answered · {tools} tools used"), + // Two numbers, drawn as two numbers. The offered count does not + // replace the asked-for one: "10 offered · 2 used" is the answer + // to a question either number alone gets wrong. + Reached::Answered { + tools, + offered: Some(offered), + } => format!("answered · {offered} offered · {tools} used"), + // No count stated. The panel says what it knows and stays silent + // about the rest rather than drawing a zero it did not hear. + Reached::Answered { + tools, + offered: None, + } => format!("answered · {tools} tools used"), Reached::Failed { tool } => format!("failed · {tool}"), Reached::NotYet => "not reached this session".to_string(), }; diff --git a/src/status.rs b/src/status.rs index 64e1d7b..6220a81 100644 --- a/src/status.rs +++ b/src/status.rs @@ -1308,10 +1308,15 @@ pub fn committed( // half-answer: three configured and none connected is the state an operator // is trying to find, and it reads exactly like a session with none configured // if only the live count is shown. + // `status.mcp.1` counts CALLS, which this sentence called tools offered until + // 0.17.0 — the one site 0.16.0's rename missed, while the status line itself + // has said `mcp N/M calls` since. It is doubly wrong now that `/mcp` draws a + // real offered count from `EventKind::Mcp`'s own `tools` field: two different + // numbers under one word is worse than either alone. facts.push(( "mcp".into(), format!( - "{} of {} configured connected, offering {} tool{}", + "{} of {} configured connected, answering {} call{}", status.mcp.0, contract.mcp.len(), status.mcp.1, diff --git a/tests/servers.rs b/tests/servers.rs index 2e4b22c..19fa44b 100644 --- a/tests/servers.rs +++ b/tests/servers.rs @@ -1,4 +1,5 @@ -//! F5 and F6 — the `/mcp` panel's three states, and the writes it makes. +//! F5, F6 and F9 — the `/mcp` panel's three states, the writes it makes, and the +//! two counts it keeps apart. use io_cli::servers::{self, Observed, Reached}; use io_harness::config::Config; @@ -92,7 +93,13 @@ fn f5_three_states_are_distinguishable_at_once() { let docs = list.iter().find(|s| s.id == "docs").unwrap(); let search = list.iter().find(|s| s.id == "search").unwrap(); - assert_eq!(docs.state, Reached::Answered { tools: 2 }); + assert_eq!( + docs.state, + Reached::Answered { + tools: 2, + offered: Some(3) + } + ); assert_eq!( search.state, Reached::Failed { @@ -127,7 +134,10 @@ fn f5_the_count_is_distinct_tools_and_never_calls() { let docs = list.iter().find(|s| s.id == "docs").unwrap(); assert_eq!( docs.state, - Reached::Answered { tools: 1 }, + Reached::Answered { + tools: 1, + offered: None + }, "five calls is one tool" ); } @@ -146,7 +156,10 @@ fn f5_a_call_is_not_counted_as_a_second_server() { assert_eq!(list.len(), 2, "the panel is the CONFIGURED set, always"); assert_eq!( list.iter().find(|s| s.id == "docs").unwrap().state, - Reached::Answered { tools: 2 } + Reached::Answered { + tools: 2, + offered: Some(3) + } ); } @@ -162,7 +175,10 @@ fn f5_an_outcome_the_event_did_not_carry_is_not_a_failure() { let list = servers::servers(&config, &observed); assert_eq!( list.iter().find(|s| s.id == "docs").unwrap().state, - Reached::Answered { tools: 1 }, + Reached::Answered { + tools: 1, + offered: None + }, "a call with no reported outcome was drawn as a failure" ); } @@ -195,6 +211,121 @@ fn f5_the_panel_reads_the_configuration_and_never_the_events_for_its_rows() { assert_eq!(ids, vec!["docs", "search"]); } +// --- F9: offered, beside asked-for -------------------------------------------- + +/// The row a server drew, by id. +fn detail(config: &Config, observed: &Observed, id: &str) -> String { + let list = servers::servers(config, observed); + let rows = servers::rows(&list); + let at = list.iter().position(|s| s.id == id).expect("a configured id"); + rows[at].detail.clone().expect("every row carries a detail") +} + +#[test] +fn f9_offered_and_asked_for_are_two_different_numbers() { + // The whole of F9: a server that announced ten tools and has been called + // twice has TWO facts about it, and neither answers for the other. Ten with + // two calls and ten with two hundred are different sessions; two asked for + // out of ten and two out of two are different servers. + let config = configured(); + let mut observed = Observed::default(); + observed.event(&reached_offering("docs", 10)); + observed.event(&called("docs", "search_docs", Some(true))); + observed.event(&called("docs", "get_page", Some(true))); + + assert_eq!( + observed.of("docs"), + Reached::Answered { + tools: 2, + offered: Some(10) + } + ); + + // And both reach the screen, as two numbers rather than one. + let drawn = detail(&config, &observed, "docs"); + assert!(drawn.contains("10 offered"), "{drawn:?}"); + assert!(drawn.contains("2 used"), "{drawn:?}"); +} + +#[test] +fn f9_a_server_that_announced_no_tools_offers_zero_and_not_unknown() { + // `Some(0)` is a STATEMENT. A server that came up with an empty catalogue is + // a configuration an operator wants to see named, and drawing it the same as + // a server whose count was never heard would hide the one thing wrong with it. + let config = configured(); + let mut observed = Observed::default(); + observed.event(&reached_offering("docs", 0)); + + assert_eq!( + observed.of("docs"), + Reached::Answered { + tools: 0, + offered: Some(0) + } + ); + let drawn = detail(&config, &observed, "docs"); + assert!(drawn.contains("0 offered"), "{drawn:?}"); +} + +#[test] +fn f9_a_server_that_has_only_been_called_does_not_report_offering_nothing() { + // THE SABOTAGE. Read a missing count as zero — `unwrap_or(0)` anywhere on the + // path — and this server, which has demonstrably answered two different tools, + // reports offering none. Every event here carries `tools: None`, because the + // count rides only the announcing event and this session never folded one in. + let config = configured(); + let mut observed = Observed::default(); + observed.event(&called("docs", "search_docs", Some(true))); + observed.event(&called("docs", "get_page", Some(true))); + + assert_eq!( + observed.of("docs"), + Reached::Answered { + tools: 2, + offered: None + }, + "a missing count was read as a stated zero" + ); + let drawn = detail(&config, &observed, "docs"); + assert!( + !drawn.contains("offered"), + "the panel claimed an offered count it never heard: {drawn:?}" + ); + assert!(drawn.contains("2 tools used"), "{drawn:?}"); +} + +#[test] +fn f9_a_later_event_without_the_count_does_not_erase_the_one_that_had_it() { + // The same sabotage from the other side: the announcing event states the + // count, then every call that follows carries `None`. Folding a `None` in as + // an assignment would delete a fact the session was told. + let mut observed = Observed::default(); + observed.event(&reached_offering("docs", 7)); + observed.event(&called("docs", "a", Some(true))); + + assert_eq!( + observed.of("docs"), + Reached::Answered { + tools: 1, + offered: Some(7) + } + ); +} + +#[test] +fn f9_a_server_never_reached_shows_neither_number() { + // Not reached is not "offered nothing, used nothing". It is the third state, + // and F5's whole point is that it stays its own thing. + let config = configured(); + let observed = Observed::default(); + + assert_eq!(observed.of("docs"), Reached::NotYet); + let drawn = detail(&config, &observed, "docs"); + assert!(!drawn.contains("offered"), "{drawn:?}"); + assert!(!drawn.contains("used"), "{drawn:?}"); + assert!(drawn.contains("not reached this session"), "{drawn:?}"); +} + // --- F6: the writes ----------------------------------------------------------- #[test] diff --git a/tests/status.rs b/tests/status.rs index 72d8fe9..56d1398 100644 --- a/tests/status.rs +++ b/tests/status.rs @@ -1589,7 +1589,12 @@ fn f10_status_commits_every_field_from_the_value_io_harness_supplied() { // What is connected, beside what was configured. assert!( - page.contains("mcp: 1 of 2 configured connected, offering 1 tool"), + // `answering N calls` and not `offering N tools`: the second number has + // counted calls since 0.10.0 and this was the one site 0.16.0's rename + // missed. `/mcp` draws the real offered count from the event's own + // `tools` field from 0.17.0, so two different numbers under one word + // would now contradict each other on two surfaces. + page.contains("mcp: 1 of 2 configured connected, answering 1 call"), "a server that answered and a server that is named in the file are \ different facts, and both are on the page: {page}", ); From d37d806aa9fc7354f97474b3c53a552871af2c59 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Wed, 26 Aug 2026 08:26:16 +0530 Subject: [PATCH 03/12] feat(queue): keep a prompt typed during a turn, and fire the queue in order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A prompt typed while a turn is in flight already reached the composer and drew there — `App::key` has never had a running guard — and the `Enter` that followed returned a submit into the driver's catch-all arm, which discarded it while the composer that held it had already cleared. The keystroke looked accepted and the prompt was gone. That is the defect this release exists to remove, and it had no test because nothing under tests/ links the binary. So the guard lives in `App::compose`, not in the driver: a mid-turn Enter queues the text and returns `Command::None`. That is the only place deciding what a mid-turn prompt becomes, which is also the seam mid-turn steering needs. `/` and `!` still fall through to the driver's existing refusal — a queue that held shell lines would put a second construction site behind the spawn ban. The queue is its own field rather than an overload of the picture queue or of `submitted`, which is the current turn's prompt and exists so a rewind can put it back. It drains one prompt per turn: three queued lines are three turns in the order they were typed, each its own exchange in the scrollback and each interruptible. Joining them would answer three questions in one breath. A turn the operator stopped drops what was waiting. One press of the stop key should not start the next three turns. Queueing announces through `say` rather than `record`: the prompt enters the transcript when it runs, as its own echo, and a line here would be two entries for one prompt. `src/main.rs` also carries the driver wiring for `/context` in this commit, because both changes edit the same file. --- src/app.rs | 110 +++++++++++++ src/main.rs | 181 ++++++++++++++++----- tests/queue.rs | 427 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 680 insertions(+), 38 deletions(-) create mode 100644 tests/queue.rs diff --git a/src/app.rs b/src/app.rs index ec8f7f8..b6aa4cf 100644 --- a/src/app.rs +++ b/src/app.rs @@ -187,6 +187,24 @@ pub struct App { /// over rather than being dropped or half-attached. The operator is told it /// is waiting, and a moment later it is `[Image #1]` like any other. queued: Vec, + /// Prompts finished while a turn was running, waiting for turns of their own. + /// + /// **A third thing, and neither of the two it sits between.** `queued` above + /// holds pictures, which are staged onto whatever prompt comes next; + /// `submitted` below is the one prompt *this* turn is about, kept single so + /// [`App::undo_turn`] has exactly one line to put back. This is a line the + /// operator finished while the session was busy: it is not part of the + /// running turn, it is not an attachment to it, and there can be several of + /// it. Overloading either neighbour would lose one of those three facts. + /// + /// Position in the vector is the whole of the ordering, and nothing here + /// carries a time — which is what lets a session that reads no clock still + /// fire these in the order they were typed. + /// + /// In memory for the length of the session and written nowhere. A prompt + /// that outlived the terminal it was typed into would come back as a turn + /// nobody asked for, against a conversation that had moved on without it. + prompts: Vec, /// Every image attached in this session, by path, oldest first. /// /// The index into it is the number in `[Image #1]`, and the path is all that @@ -258,6 +276,7 @@ impl App { armed: None, contained: false, queued: Vec::new(), + prompts: Vec::new(), images: Vec::new(), turn_rows: 0, echo_rows: 0, @@ -600,6 +619,73 @@ impl App { std::mem::take(&mut self.queued) } + /// Hold a prompt the operator finished while a turn had the session. + /// + /// **Kept, rather than sent or dropped.** Up to 0.16.0 this keystroke looked + /// accepted and was not: the composer empties on `Enter` whatever happens + /// next, so the line disappeared from the prompt exactly as a sent one does, + /// and then fell through the driver's catch-all and was gone. That is the + /// worst shape a lost keystroke can take — there is no error, no refusal and + /// no text left to press `Enter` on again. Holding it is the smallest honest + /// answer: the session cannot take it now, so it takes it next. + /// + /// The notice is the footer's rather than the scrollback's, because a queued + /// prompt is not yet part of the record. It becomes part of it when it runs, + /// as its own exchange with its own echo, and a line here would be a second + /// entry for one prompt. What keeps it visible in between is the queue + /// itself — state, which a surface can draw and a notice cannot. + pub fn queue_prompt(&mut self, text: impl Into) { + self.prompts.push(text.into()); + let waiting = self.prompts.len(); + let dash = self.theme.glyphs.dash; + // Muted, like the picture held one line over: nothing has gone wrong and + // nothing was refused. The count is in the sentence because the second + // and third prompt otherwise produce a notice identical to the first, + // which reads as a keystroke that did nothing. + self.say( + Tone::Muted, + if waiting == 1 { + format!("queued {dash} it runs when this turn ends") + } else { + format!("{waiting} queued {dash} they run in order when this turn ends") + }, + ); + } + + /// The prompt that has waited longest, taken off the front. + /// + /// **One at a time, and the driver runs a whole turn between two calls.** + /// That is what makes three queued lines three turns rather than one turn + /// carrying three questions: each gets its own echo, its own answer under it + /// and its own `Ctrl+C`. Joined into one prompt they would be a run that + /// answers everything in one breath and cannot be stopped part-way through, + /// which is the opposite of what queueing them was for. + pub fn next_queued_prompt(&mut self) -> Option { + if self.prompts.is_empty() { + return None; + } + Some(self.prompts.remove(0)) + } + + /// What is waiting, oldest first. + /// + /// For a surface that draws the queue and for the tests. The driver takes + /// them one at a time through [`App::next_queued_prompt`] instead, so there + /// is no path that reads the whole queue in order to run it. + pub fn queued_prompts(&self) -> &[String] { + &self.prompts + } + + /// Drop everything still waiting, and report how much was dropped. + /// + /// An operator stopping a turn is stopping the session, not just the step in + /// front of them. A queue that fired anyway would make the stop key start + /// three more turns, which is a key that reads as broken — and the prompts + /// were typed against a conversation that was going somewhere else. + pub fn forget_queued_prompts(&mut self) -> usize { + std::mem::take(&mut self.prompts).len() + } + /// Remember an attached image and return the number its marker carries. /// /// One-based, because the marker is read by a person: `[Image #1]` is the @@ -1262,6 +1348,10 @@ impl App { /// `!` with nothing after it is nothing to run, and does nothing. It is not /// treated as a prompt, because a bare `!` submitted to a model is a /// keystroke that missed rather than a question. + /// + /// The first character decides *what* the line is; the mode decides whether a + /// prompt can be sent at all. A prompt finished while a turn holds the + /// session is queued rather than returned — see [`App::queue_prompt`]. fn compose(&mut self, key: KeyEvent) -> Command { self.quits = 0; match self.composer.key(key) { @@ -1271,6 +1361,26 @@ impl App { None => match text.strip_prefix('!').map(str::trim) { Some("") => Command::None, Some(line) => Command::Shell(line.to_string()), + // **The guard is here and not in the driver.** A + // `Command::Submit` handed out mid-turn is an instruction + // nobody can carry out: there is one session, one turn may + // hold it, and every caller of [`App::key`] would otherwise + // have to know that separately — which is how the driver's + // turn loop came to drop this line in its catch-all while the + // idle loop ran it. One session-wide fact, asked once, where + // the mode is already known. + // + // The two arms above deliberately keep falling through. A + // slash command and a `!` line are *refused* mid-turn with a + // sentence, and refusing is right for them: `/model` or + // `/fork` held for later would take effect at a moment nobody + // could predict, and a shell line is the operator's own, + // wanted now or not at all. Only a prompt is the kind of + // thing that keeps its meaning after the turn in front of it. + None if self.mode == Mode::Running => { + self.queue_prompt(text); + Command::None + } None => Command::Submit(text), }, }, diff --git a/src/main.rs b/src/main.rs index a77f1f0..f281dc4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -471,6 +471,16 @@ async fn loop_over Result>( skills: io_harness::Skills, model: String, ) -> Result<(), String> { + // Every request the session makes goes past this, and it is the only way + // io-cli can say what is in the model's window: io-harness enumerates none + // of it — the composer is private and the event announcing a composed prompt + // carries a byte count with no text — while the request it hands the caller + // carries the system block, the tool catalogue and the messages as public + // fields. The MAKER is wrapped rather than the provider it makes, so a + // `/model` switch keeps reporting rather than quietly reverting to a + // provider nothing is watching. + let seen = io_cli::context::Seen::default(); + let make = io_cli::provider::watching(make, seen.clone()); // Built here rather than handed in, so there is one place a provider comes // from and `/model` cannot drift from startup. let mut provider = make(&model)?; @@ -677,6 +687,9 @@ async fn loop_over Result>( // longer on screen. app.status.forget_run(); app.forget_fleet(); + // A window that outlives its conversation + // describes a turn the operator has left. + seen.forget(); // Where they were, in the terminal's own // buffer rather than in a four-row viewport. commit_transcript(screen, &session, &store, &app.theme)?; @@ -713,6 +726,9 @@ async fn loop_over Result>( Ok(()) => { app.status.forget_run(); app.forget_fleet(); + // A window that outlives its conversation + // describes a turn the operator has left. + seen.forget(); app.say( Tone::Success, format!( @@ -1041,6 +1057,8 @@ async fn loop_over Result>( // The undone turn is where those numbers came from. app.status.forget_run(); app.forget_fleet(); + // As above: the last request belonged to the undone turn. + seen.forget(); for (tone, line) in io_cli::rewind::undone_lines(&undone, &app.theme.glyphs) { app.say(tone, line); } @@ -1420,6 +1438,28 @@ async fn loop_over Result>( ); screen.commit(&lines).map_err(|error| error.to_string())?; } + Action::Context => { + // `reading` for the same reason the arm above binds it that + // way — `tests/plan.rs` reads the plan-gate argument off a + // binding called `contract`, and this is not a turn's. + let (answerer, _questions) = io_cli::intent::channel(); + let reading = io_cli::contract::session( + String::new(), + session.root().to_path_buf(), + &config, + &capabilities, + std::sync::Arc::new(answerer), + None, + ); + let lines = io_cli::context::committed( + seen.latest().as_ref(), + &reading, + reading.max_tokens, + &app.theme, + screen.width(), + ); + screen.commit(&lines).map_err(|error| error.to_string())?; + } Action::Copy(what) => { let (payload, said) = to_copy(&session, &store, what); match payload { @@ -1508,43 +1548,81 @@ async fn loop_over Result>( let lines = shell::lines(&line, &ran, &app.theme); screen.commit(&lines).map_err(|error| error.to_string())?; } + // **A prompt, and then whatever queued behind it while it ran.** The + // loop is what makes three queued lines three turns: each goes + // through `turn` on its own, so each gets its own echo, its own + // answer under it, its own clock and its own `Ctrl+C`. Joining them + // into one prompt would be one run answering everything in one + // breath, with no boundary an operator could stop it at — which is + // the shape queueing them was meant to avoid. + // + // Prompts typed *during* a queued turn queue behind the rest, because + // `App::next_queued_prompt` is asked again at the bottom of every + // pass rather than the queue being drained into a list up front. Command::Submit(text) => { - // Rebuilt every turn rather than kept, because `remembered` grows - // as the operator answers and the harness's own `remember` dies - // with the turn it was given in. With nothing remembered this is - // the session's policy unchanged. - let effective = approval::session_policy(&policy, app.posture(), app.remembered()); - turn( - screen, - inputs, - &mut app, - &provider, - &store, - &mut session, - &effective, - &config, - // The caps reach the turn only while the session is in - // contained mode, so `/contain off` is a real switch and not - // a label: with `None` here the turn built below is the - // steered turn, byte for byte. - contained.then_some(containment.as_ref()).flatten(), - &capabilities, - planning, - text, - // **This turn's own clock, not the session's.** What a reader - // wants of the row above the prompt is how long the thing in - // front of them has been going; a clock that had been counting - // since the terminal opened said `22m12s` about a turn six - // seconds old. Every event age inside the turn is measured - // from here too, which is what a tool cell's duration is a - // difference of. - Instant::now(), - ) - .await?; - // Anything dropped onto the prompt while the turn held the - // session is staged now that it has let go. - for path in app.take_queued_pictures() { - paste_picture(&mut app, &mut session, &provider, &policy, &path); + let mut next = Some(text); + while let Some(text) = next.take() { + // Rebuilt every turn rather than kept, because `remembered` + // grows as the operator answers and the harness's own + // `remember` dies with the turn it was given in. With nothing + // remembered this is the session's policy unchanged. + // + // Inside the loop for the same reason it was ever rebuilt: a + // queued turn runs under what the operator has allowed by the + // time it starts, not by the time they typed it. + let effective = + approval::session_policy(&policy, app.posture(), app.remembered()); + let stopped = turn( + screen, + inputs, + &mut app, + &provider, + &store, + &mut session, + &effective, + &config, + // The caps reach the turn only while the session is in + // contained mode, so `/contain off` is a real switch and + // not a label: with `None` here the turn built below is + // the steered turn, byte for byte. + contained.then_some(containment.as_ref()).flatten(), + &capabilities, + planning, + text, + // **This turn's own clock, not the session's.** What a + // reader wants of the row above the prompt is how long the + // thing in front of them has been going; a clock that had + // been counting since the terminal opened said `22m12s` + // about a turn six seconds old. Every event age inside the + // turn is measured from here too, which is what a tool + // cell's duration is a difference of. + // + // Read once per pass, so a queued turn is timed from the + // moment it starts rather than from the prompt that ran + // ahead of it. + Instant::now(), + ) + .await?; + // Anything dropped onto the prompt while the turn held the + // session is staged now that it has let go. + for path in app.take_queued_pictures() { + paste_picture(&mut app, &mut session, &provider, &policy, &path); + } + // The stop key stops the session, not just the step in front + // of the operator. Firing the queue here would turn one press + // into three more turns against a conversation they had just + // decided to steer somewhere else. + if stopped { + let dropped = app.forget_queued_prompts(); + if dropped > 0 { + let dash = app.theme.glyphs.dash; + app.say( + Tone::Muted, + format!("{dropped} queued {dash} dropped with the stopped turn"), + ); + } + } + next = app.next_queued_prompt(); } } } @@ -1672,6 +1750,11 @@ fn commit_viewed( } /// One turn, with the keyboard live throughout so `Ctrl+C` can reach it. +/// +/// Returns whether the operator asked this turn to stop — the one thing about a +/// turn the caller cannot see from outside it, and the thing the caller now has +/// to decide about, because a prompt queue that fired after a stop would make the +/// stop key start three more turns. #[allow(clippy::too_many_arguments)] async fn turn( screen: &mut Screen>, @@ -1694,7 +1777,7 @@ async fn turn( planning: bool, text: String, started: Instant, -) -> Result<(), String> { +) -> Result { let (observer, mut events) = bridge::channel(); // The one way a turn is stopped from the interface, contained or not. Both // arms take a contract now and neither takes a steer inbox, so `Flow::Cancel` @@ -1795,6 +1878,11 @@ async fn turn( // Set when a turn was taken back off the screen rather than stopped: there // is nothing to report about a turn the session no longer shows. let mut undone = false; + // Set by either stop key, and read by the caller. Both arms below are the + // operator asking for this turn to end: the first press cancels at a step + // boundary and the second drops the future, and neither is visible in what + // io-harness returns — a cancelled run comes back as an ordinary result. + let mut stopped = false; let mut ticker = tokio::time::interval(io_cli::app::TICK); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); @@ -1864,6 +1952,7 @@ async fn turn( // boundary. That is the sentence // `App::interrupt_or_quit` has just put on screen. canceller.store(true, std::sync::atomic::Ordering::Relaxed); + stopped = true; } // **The second press, and it does not wait.** A // cancel is honoured at a step boundary, and a step @@ -1879,6 +1968,7 @@ async fn turn( // commits whatever streamed before it. Command::Abandon => { canceller.store(true, std::sync::atomic::Ordering::Relaxed); + stopped = true; // **A turn that had done nothing is taken back // whole.** `App::undoable` is what decides that // — no step, nothing streamed, nothing on screen @@ -1937,6 +2027,20 @@ async fn turn( ), ) } + // **`Command::Submit` no longer arrives here, and + // that is the fix.** Through 0.16.0 a prompt typed + // mid-turn reached this arm and was dropped without a + // word — the worst shape a lost keystroke can take, + // because the composer had already emptied and there + // was nothing left to press `Enter` on again. + // `App::compose` now queues it instead, so what falls + // through here is only what this loop has always + // ignored. The guard is in the library rather than in + // this arm on purpose: nothing in `src/main.rs` is + // linked by an integration test, so a branch written + // here could not be sabotaged and would not be + // covered — `tests/queue.rs` asserts the queueing + // where a test can reach it. _ => {} } } @@ -2010,7 +2114,8 @@ async fn turn( Some(Ok(_)) => {} } app.status.elapsed = started.elapsed(); - paint(screen, app) + paint(screen, app)?; + Ok(stopped) } /// Draw what a step changed, by asking the store what it recorded. diff --git a/tests/queue.rs b/tests/queue.rs new file mode 100644 index 0000000..71b9062 --- /dev/null +++ b/tests/queue.rs @@ -0,0 +1,427 @@ +//! F1 — a prompt typed during a turn is kept. +//! F4 — the queue fires in order when the turn ends, one prompt per turn. +//! +//! Asserted against `App` rather than against the driver, because `src/main.rs` +//! is linked by no integration test: a guard written in the turn loop could not +//! be sabotaged and would not be covered, which is the reason the queueing +//! decision lives in `App::compose` at all. What the driver still owns is the +//! *drain* — a whole turn between two `App::next_queued_prompt` calls — and the +//! shape of that loop is asserted here the only way a test can reach it: by +//! doing what the driver does, one prompt at a time, and checking that three +//! queued lines produce three exchanges rather than one. +//! +//! Nothing here draws the queue. The surface is its own criterion; this file is +//! state, capture and drain. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use io_cli::app::{App, Command, Mode}; +use io_cli::theme::DARK; +use io_harness::{EventKind, RunEvent}; + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +fn type_text(app: &mut App, text: &str) { + for character in text.chars() { + app.key(key(KeyCode::Char(character))); + } +} + +fn text_of(lines: &[ratatui::text::Line<'_>]) -> String { + lines + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::>() + .join("") +} + +/// Type a line and press `Enter`, the way an operator sends one. +fn send(app: &mut App, text: &str) -> Command { + type_text(app, text); + app.key(key(KeyCode::Enter)) +} + +/// Start a turn the way the driver does: the mode, then the harness's own +/// `Started`, which is what puts the prompt's echo in the scrollback. +fn start_turn(app: &mut App, goal: &str) { + app.started(); + app.event( + &RunEvent::new( + 1, + 1, + EventKind::Started { + goal: goal.into(), + provider: "test".into(), + }, + ), + std::time::Duration::ZERO, + ); +} + +/// Stream one answer and end the turn. +fn answer_turn(app: &mut App, text: &str) { + app.event( + &RunEvent::new( + 1, + 2, + EventKind::Token { + text: format!("{text}\n"), + }, + ), + std::time::Duration::ZERO, + ); + app.finished(); +} + +#[test] +fn f1_a_prompt_typed_during_a_turn_is_kept_in_the_order_it_was_typed() { + let mut app = App::new(DARK, "anthropic/claude-sonnet-4"); + + // A turn is in flight. Everything below happens while it holds the session. + start_turn(&mut app, "refactor the parser"); + assert_eq!(app.mode(), Mode::Running); + + // The first prompt. Nothing is returned to the driver — a `Command::Submit` + // handed out here is an instruction nobody could carry out, and through + // 0.16.0 it was handed out anyway and dropped in the turn loop's catch-all. + assert_eq!(send(&mut app, "and add a test for it"), Command::None); + assert!( + app.composer.is_empty(), + "the composer clears on Enter whether or not the line was sent, which is \ + exactly why losing it here was invisible", + ); + assert_eq!(app.queued_prompts(), ["and add a test for it"]); + + // A second and a third queue behind it rather than replacing it. + assert_eq!(send(&mut app, "then update the README"), Command::None); + assert_eq!(send(&mut app, "finally run the whole suite"), Command::None); + assert_eq!( + app.queued_prompts(), + [ + "and add a test for it", + "then update the README", + "finally run the whole suite", + ], + "the queue is send order, and send order is the order they were typed", + ); + + // Nothing was discarded and nothing was sent: the turn that was running is + // still the turn that is running. + assert_eq!(app.mode(), Mode::Running); +} + +#[test] +fn f1_queueing_says_so_and_says_how_many_are_waiting() { + let mut app = App::new(DARK, "m"); + start_turn(&mut app, "the turn in flight"); + + send(&mut app, "one"); + let first = notice(&app); + assert!( + first.contains("queued"), + "a keystroke that emptied the composer and did nothing visible is the \ + defect this criterion is about: {first:?}", + ); + + // The count is in the sentence, or the second prompt produces a notice + // identical to the first — which reads as a key that did nothing. + send(&mut app, "two"); + let second = notice(&app); + assert!(second.contains('2'), "{second:?}"); +} + +#[test] +fn f4_three_queued_lines_become_three_turns_in_order_each_its_own_exchange() { + let mut app = App::new(DARK, "m"); + + start_turn(&mut app, "the first prompt"); + send(&mut app, "the second prompt"); + send(&mut app, "the third prompt"); + send(&mut app, "the fourth prompt"); + answer_turn(&mut app, "answer to the first prompt"); + // The turn that was running is committed and out of the way, so what the + // drain below produces is the queue's and nothing else's. + app.take_pending(); + + // What the driver does: take one, run a whole turn, come back for the next. + // The queue is asked again each pass rather than drained into a list, which + // is what lets a prompt typed during a queued turn go behind the rest. + let mut exchanges = Vec::new(); + while let Some(prompt) = app.next_queued_prompt() { + start_turn(&mut app, &prompt); + answer_turn(&mut app, &format!("answer to {prompt}")); + exchanges.push((prompt, text_of(&app.take_pending()))); + } + + assert_eq!(exchanges.len(), 3, "three queued lines are three turns"); + let order: Vec<&str> = exchanges + .iter() + .map(|(prompt, _)| prompt.as_str()) + .collect(); + assert_eq!( + order, + ["the second prompt", "the third prompt", "the fourth prompt"], + "the queue fired out of the order it was typed in", + ); + + // Each turn is its own exchange: its own echo, its own answer under it, and + // no trace of the two it is not about. A queue joined into one prompt would + // put all three questions in the first exchange and leave the other two + // empty — a run that answers everything in one breath and cannot be stopped + // between the parts. + for (prompt, scrollback) in &exchanges { + assert!( + scrollback.contains(prompt.as_str()), + "{prompt:?} was not echoed into its own exchange: {scrollback:?}", + ); + assert!( + scrollback.contains(&format!("answer to {prompt}")), + "{prompt:?} did not get its own answer: {scrollback:?}", + ); + for (other, _) in &exchanges { + assert!( + other == prompt || !scrollback.contains(other.as_str()), + "{other:?} appeared in the exchange for {prompt:?}: {scrollback:?}", + ); + } + } +} + +#[test] +fn f4_the_queue_hands_back_the_lines_as_they_were_typed_never_joined() { + let mut app = App::new(DARK, "m"); + start_turn(&mut app, "the turn in flight"); + send(&mut app, "what does this module do"); + send(&mut app, "now write the test"); + app.finished(); + + // Taken one at a time and byte for byte. Joining them — with a newline, a + // space or anything else — would be one prompt where three were typed, and + // this is the assertion that catches it whatever the separator was. + assert_eq!( + app.next_queued_prompt().as_deref(), + Some("what does this module do"), + ); + assert_eq!( + app.next_queued_prompt().as_deref(), + Some("now write the test") + ); + assert_eq!(app.next_queued_prompt(), None); + assert!(app.queued_prompts().is_empty()); +} + +#[test] +fn a_prompt_typed_during_a_queued_turn_goes_behind_what_is_still_waiting() { + let mut app = App::new(DARK, "m"); + + start_turn(&mut app, "the first prompt"); + send(&mut app, "the second prompt"); + send(&mut app, "the third prompt"); + app.finished(); + + // The queued turn starts, and the operator types again while it runs. + let second = app.next_queued_prompt().expect("the queue had two"); + start_turn(&mut app, &second); + send(&mut app, "the fourth prompt"); + assert_eq!( + app.queued_prompts(), + ["the third prompt", "the fourth prompt"], + "a prompt typed during a queued turn must not jump the queue", + ); + app.finished(); + + assert_eq!( + app.next_queued_prompt().as_deref(), + Some("the third prompt") + ); + assert_eq!( + app.next_queued_prompt().as_deref(), + Some("the fourth prompt") + ); +} + +#[test] +fn an_idle_prompt_is_sent_rather_than_queued() { + let mut app = App::new(DARK, "m"); + + // The guard is on the mode and nothing else. A queue that also swallowed the + // ordinary prompt would be a session that never runs anything. + assert_eq!( + send(&mut app, "what does this module do"), + Command::Submit("what does this module do".into()), + ); + assert!(app.queued_prompts().is_empty()); + + // And after the turn it started has ended, the next one is sent again. + start_turn(&mut app, "what does this module do"); + app.finished(); + assert_eq!(send(&mut app, "again"), Command::Submit("again".into())); + assert!(app.queued_prompts().is_empty()); +} + +#[test] +fn a_slash_command_typed_mid_turn_is_still_refused_rather_than_queued() { + let mut app = App::new(DARK, "m"); + start_turn(&mut app, "the turn in flight"); + + // `/model` or `/fork` held until the turn ended would take effect at a moment + // nobody could predict. The driver refuses them with a sentence; only a + // prompt is the kind of thing that keeps its meaning after the turn in front + // of it, so only a prompt is queued. + assert_eq!( + send(&mut app, "/model gpt-5"), + Command::Slash("model gpt-5".into()) + ); + assert!(app.queued_prompts().is_empty()); +} + +#[test] +fn a_shell_line_typed_mid_turn_is_still_refused_rather_than_queued() { + let mut app = App::new(DARK, "m"); + start_turn(&mut app, "the turn in flight"); + + // The operator's own line, wanted now or not at all — and the agent never + // hears about it either way, so there is nothing for a queue to sequence it + // against. + assert_eq!( + send(&mut app, "!git status"), + Command::Shell("git status".into()) + ); + assert!(app.queued_prompts().is_empty()); +} + +#[test] +fn a_blank_line_mid_turn_queues_nothing() { + let mut app = App::new(DARK, "m"); + start_turn(&mut app, "the turn in flight"); + + // The composer refuses to submit whitespace, so this never reaches the queue + // — asserted rather than assumed, because a queue that accepted empty lines + // would spend a turn each on them. + assert_eq!(send(&mut app, " "), Command::None); + assert!(app.queued_prompts().is_empty()); + assert!( + app.status.notice.is_none(), + "nothing happened, so nothing is said" + ); +} + +#[test] +fn the_queue_is_not_the_prompt_the_running_turn_is_about() { + let mut app = App::new(DARK, "m"); + + start_turn(&mut app, "the prompt this turn is about"); + send(&mut app, "the prompt waiting behind it"); + + // `submitted` is single-valued and stays that way: an undone turn puts back + // the line it was about, not the line typed after it and not the two joined. + let (_, restored) = app.undo_turn(); + assert_eq!(restored, "the prompt this turn is about"); + assert_eq!(app.composer.text(), "the prompt this turn is about"); + + // The queue is untouched by the undo. Dropping it is the driver's decision, + // taken because the operator pressed a stop key — see the test below — and + // not a side effect of the turn's rows coming off the screen. + assert_eq!(app.queued_prompts(), ["the prompt waiting behind it"]); +} + +#[test] +fn a_stopped_turn_drops_what_was_waiting_behind_it() { + let mut app = App::new(DARK, "m"); + start_turn(&mut app, "the turn in flight"); + send(&mut app, "one"); + send(&mut app, "two"); + app.finished(); + + // The stop key stops the session, not just the step in front of the + // operator. A queue that fired anyway would make one press start two more + // turns against a conversation they had just decided to steer elsewhere. + assert_eq!( + app.forget_queued_prompts(), + 2, + "the count is what the driver says" + ); + assert!(app.queued_prompts().is_empty()); + assert_eq!(app.next_queued_prompt(), None); + assert_eq!(app.forget_queued_prompts(), 0, "nothing left to drop"); +} + +#[test] +fn the_queue_starts_empty_and_is_never_read_from_anywhere_else() { + // A session that has run nothing has nothing waiting, which is the state the + // surface has to render on the very first frame. + let app = App::new(DARK, "m"); + assert!(app.queued_prompts().is_empty()); +} + +/// **F4, the half that lives in the driver.** The drain is one turn per prompt. +/// +/// `src/main.rs` is linked by no integration test, so the loop that turns a queued +/// prompt back into a turn is a decision nothing can drive — the same problem +/// `tests/structure.rs` solves for the order of two calls in the same file, and +/// the same answer: read the driver. The offsets come from the source with its +/// comments stripped, so the paragraph you are reading could be pasted into the +/// driver and this test would still fail. +/// +/// What it pins is the shape a joined queue cannot have: the arm asks for one +/// prompt, runs a whole turn, and comes back — rather than collecting the queue +/// and sending it as a single prompt, which is a run that answers three questions +/// in one breath with no boundary an operator can stop it at. +#[test] +fn f4_the_driver_runs_one_turn_per_queued_prompt() { + let driver = driver_without_comments(); + let arm = driver + .split_once("Command::Submit(text) => {") + .expect("the driver dispatches a submitted prompt") + .1 + .split_once("\n }") + .expect("the arm closes") + .0; + + assert!( + arm.contains("while let Some(text) = next.take()"), + "a submitted prompt is followed by whatever queued behind it, in a loop; \ + without one the queue is captured and never fires:\n{arm}", + ); + assert!( + arm.contains("next = app.next_queued_prompt();"), + "the queue is asked again at the bottom of every pass, which is what puts a \ + prompt typed during a queued turn behind the rest:\n{arm}", + ); + assert!( + !arm.contains("join("), + "the queue is never joined into one prompt — three lines are three turns, \ + each with its own echo, its own answer and its own Ctrl+C:\n{arm}", + ); + assert!( + arm.contains("app.forget_queued_prompts()"), + "a stopped turn drops what was waiting behind it, or one press of the stop \ + key starts the next three turns:\n{arm}", + ); +} + +/// `src/main.rs` with every trailing comment removed, so the assertions above are +/// over the code that runs rather than over prose about it. The same helper +/// `tests/structure.rs` reads the driver with; duplicated rather than shared +/// because `tests/support/mod.rs` does not carry it yet. +fn driver_without_comments() -> String { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/main.rs"); + let text = std::fs::read_to_string(path).expect("the driver is readable"); + text.lines() + .map(|line| match line.find("//") { + Some(at) => &line[..at], + None => line, + }) + .collect::>() + .join("\n") +} + +fn notice(app: &App) -> String { + app.status + .notice + .as_ref() + .map(|(_, text)| text.clone()) + .unwrap_or_default() +} From 4da856689ca255503695f5b6a0462894a93ce01a Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Wed, 26 Aug 2026 08:26:30 +0530 Subject: [PATCH 04/12] feat(context): `/context` says what is in the window, read off the request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit io-harness enumerates no context window: the prompt composer is private, the event announcing a composed prompt carries a byte count and deliberately no text, and the tool catalogue offered to the model is behind no public accessor at all. What it does hand the caller is the `CompletionRequest` itself, whose `system`, `tools` and `messages` are public — and io-cli already routes every provider through one generic seam. So a decorator over that seam records the request on its way past and `/context` renders what was actually sent, tools this crate never registered included. The maker is wrapped rather than the provider it makes, so a `/model` switch keeps reporting instead of quietly reverting to a provider nothing watches. All twelve trait methods are delegated: nine are defaulted, and a default firing here would change behaviour silently — the trace would lose the provider's name, attachments would start being refused, and streaming would stop. The sections are a disjoint partition of the request rather than six overlapping wholes, because the harness puts the instructions inside the system block, the MCP tools inside the tool list and the recalled memory inside the user turn. Reporting each whole would double-count and the page would exceed the request it describes. Every count is io-harness's own `estimate_tokens`, the same estimator its assembler uses, and the denominator is the budget the operator's own configuration put on the contract. The window is forgotten wherever the run is: a window that outlives its conversation describes a turn the operator has left. Four gates moved, and none was relaxed to a tolerance: * the hand-written command list, the README table and the palette's narrowing counts all take `/context` — the gates that make adding a command a decision somebody makes. * `f6_both_arms_are_handed_one_contract` counts a fourth `contract::session` call. It is a reader bound as `reading`, not a turn bound as `contract`, which is the distinction the assertion above it rests on. * `f7_no_source_file_loops_over_provider_responses` exempts the decorator's forwards BY PATH and adds a new assertion keeping the exemption a boundary: a forward hands the call to the inner provider and must never read what comes back. The forwards are subtracted rather than the file skipped, so the module is still held to the no-top-level-loop rule — the half of that gate that matters most. Same shape as 0.7.0's amendment of the spawn ban. --- README.md | 1 + src/commands.rs | 19 ++ src/context.rs | 527 ++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/provider.rs | 126 +++++++++- tests/commands.rs | 3 + tests/context.rs | 475 +++++++++++++++++++++++++++++++++++++ tests/contract.rs | 15 +- tests/dependencies.rs | 44 +++- tests/palette.rs | 16 +- 10 files changed, 1211 insertions(+), 16 deletions(-) create mode 100644 src/context.rs create mode 100644 tests/context.rs diff --git a/README.md b/README.md index 5e4a767..16dd3cb 100644 --- a/README.md +++ b/README.md @@ -336,6 +336,7 @@ above a row that ranked there for reasons having nothing to do with it. | --- | --- | | `/help` | this table | | `/status` | commit the whole session state into the scrollback | +| `/context` | what is in the model's window, read from the request that carried the turn | | `/expand` | commit the last step's full detail into the scrollback | | `/fleet` | show the children this turn has spawned | | `/mcp` | the MCP servers configured, and what this session has seen of each | diff --git a/src/commands.rs b/src/commands.rs index bfb0e9f..36a9981 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -137,6 +137,13 @@ pub const COMMANDS: &[(&str, &str)] = &[ "/status", "commit the whole session state into the scrollback", ), + // `/status` says how full the window is; this says what is in it. The two are + // one keystroke apart on purpose — the percentage is what makes an operator + // ask the question this answers. + ( + "/context", + "what is in the model's window, read from the request that carried the turn", + ), ("/copy", "put the last answer on the system clipboard"), ( "/copy diff", @@ -241,6 +248,7 @@ pub const GROUPS: &[(Group, &[&str])] = &[ &[ "/help", "/status", + "/context", "/expand", "/fleet", "/mcp", @@ -662,6 +670,16 @@ pub enum Action { /// budgets in force, the context fill, the servers that came up — so what is /// committed is the state io-harness is in and not io-cli's account of it. Status, + /// Commit what the model's window actually held, section by section. + /// + /// Read off the request that carried the last turn and never reconstructed: + /// io-harness enumerates no context window, its prompt composer is private + /// and the event announcing a composed prompt carries a byte count with no + /// text. What it does hand the caller is the `CompletionRequest` itself, so + /// the catalogue on this page includes tools io-cli never registered — + /// because it is the catalogue the model was given rather than the one this + /// crate believes it asked for. + Context, /// Put something on the system clipboard over OSC 52. Copy(Copied), /// Put the whole conversation back into the scrollback. @@ -883,6 +901,7 @@ pub fn parse(input: &str, keys: &Keys, theme: &Theme) -> Action { // name for a surface nobody has typed yet is a name to keep working // forever in exchange for nothing. "status" => Action::Status, + "context" => Action::Context, // `/clear` and `/new` mean the same thing, for the reason `/resume` and // `/continue` do: both words are in the field's vocabulary and a reader // arrives having been taught one of them by another agent. diff --git a/src/context.rs b/src/context.rs new file mode 100644 index 0000000..f95945a --- /dev/null +++ b/src/context.rs @@ -0,0 +1,527 @@ +//! What the request that carried this turn actually contained. +//! +//! **Read from the request, and there is no other honest source.** io-harness +//! enumerates no context window: `run::prompts::compose` is `pub(super)`, +//! `workspace_tools()` is `pub(super)`, `Assembled` is built inside the step loop +//! and never returned, and `EventKind::PromptComposed` carries a *byte count* and +//! deliberately no prompt text. So every accessor that could answer "what is in +//! the window" is behind a wall, and everything io-cli could reach instead is a +//! reconstruction — a second opinion about a prompt this crate did not compose, +//! which would drift from the real one the first time the harness moved a +//! section. +//! +//! What is not behind a wall is the wire. Every completion this session makes +//! passes through the caller's own [`Provider`](io_harness::Provider), and +//! `CompletionRequest` has all-public fields: `system`, `user`, `messages` and +//! `tools`. io-cli already routes every provider through one seam — +//! [`crate::provider::WithProvider`] — so a decorator that implements `Provider` +//! by delegating and keeps a copy of the request on the way past is the whole +//! mechanism. See [`crate::provider::Watched`]; this module is what it fills and +//! what that is drawn as. +//! +//! # Why the sections are a partition and not a list +//! +//! F7 says the sections "sum to a total". A list of overlapping counts sums to +//! nothing an operator can act on: the repository's instructions are *inside* the +//! system block (`compose` frames them as a `repository_guidance` section), the +//! MCP tools are *inside* the tool catalogue (they are the members of +//! `request.tools` carrying [`MCP_TOOL_PREFIX`](io_harness::MCP_TOOL_PREFIX)), +//! and the recalled memory is *inside* the conversation (`assemble` pushes the +//! `[memory]` block onto the front of the assembled text, which the user turn +//! carries). Reporting each whole would count some bytes three times and produce +//! a total larger than the request. +//! +//! So each of the six sections is a **disjoint region of the request**, located +//! by the text io-harness actually wrote: +//! +//! * the repository's instructions are `contract.instructions` joined the way +//! `instructions_section` joins them, found as a substring of `request.system`; +//! * the system block is the rest of `request.system`; +//! * the MCP tools are the prefixed entries of `request.tools`, grouped by the +//! server id the contract configured; +//! * the tool catalogue is the rest of `request.tools`; +//! * the recalled memory is the `[memory]` block located in the conversation; +//! * the conversation is the rest of it. +//! +//! Summing them is then a statement about the request rather than an arithmetic +//! coincidence, which is exactly what makes a wrong section detectable: a number +//! that cannot be reconciled with the total beside it is the failure this design +//! exists to make visible. +//! +//! # Why every count is io-harness's estimator +//! +//! [`io_harness::context::estimate_tokens`] is public, and it is the same +//! function `ContextBudget` is spent in and `Assembled::est_tokens` reports. A +//! heuristic of io-cli's own would put a number beside a ceiling that was +//! measured in different units — the page would be arithmetically consistent with +//! itself and wrong about the only comparison it exists to make. +//! +//! The denominator is [`ContextBudget::effective_tokens`], read off the +//! *contract* rather than off `ContextBudget::default()`, because the operator's +//! `[context]` configuration is already resolved onto the contract io-cli builds +//! and reading the file a second time here would be a second answer to a settled +//! question. + +use std::sync::{Arc, Mutex}; + +use io_harness::context::estimate_tokens; +use io_harness::{CompletionRequest, Message, TaskContract, ToolSpec, MCP_TOOL_PREFIX}; +use ratatui::text::{Line, Span}; + +use crate::theme::{Theme, Tone}; + +/// The head of the memory block, as `io_harness::context::render_notes` writes +/// it. +/// +/// Matched on the opening sentence rather than on the whole paragraph: the +/// sentence is the part that identifies the block, and pinning the entire wording +/// would turn a copy-edit in io-harness into a section that silently reports +/// zero. The two forms are separate constants because a run with only +/// every-workspace notes renders the second head and never the first. +const MEMORY_HEAD: &str = "[memory] Notes you recorded on earlier runs"; +/// The head of the every-workspace half of the same block (io-harness 0.56.0). +const MEMORY_GLOBAL_HEAD: &str = "[memory: every workspace] Notes kept for every workspace"; + +/// One request, as it went out. +/// +/// A copy rather than a borrow, because the request is consumed by the provider +/// call that carries it and the page is drawn some keystrokes later. Only the +/// four fields a window is made of are kept: this is not a replay log, and the +/// credential-bearing parts of a provider never come near it. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Request { + /// `CompletionRequest::system`, whole — instructions still inside it. + pub system: String, + /// `CompletionRequest::tools`, exactly as offered. + /// + /// **The catalogue, not io-cli's belief about it.** A tool reaches this list + /// because the harness put it on the wire, so a server that came up late, a + /// plugin, and a tool this crate has never heard of are all present — which + /// is the whole reason the page is read from the request. + pub tools: Vec, + /// The conversation this request carried, flattened to the text it is made + /// of. + /// + /// `messages` when it is non-empty and `user` when it is not: io-harness + /// fills `user` for every request and fills `messages` too once a run has + /// driven a step, and counting both would count the same turn twice. + pub conversation: String, + /// How many messages that was — `0` for a flat request. + pub messages: usize, +} + +impl Request { + /// The snapshot of one outgoing request. + pub fn of(request: &CompletionRequest) -> Self { + Self { + system: request.system.clone(), + tools: request.tools.clone(), + conversation: if request.messages.is_empty() { + request.user.clone() + } else { + request.messages.iter().map(message_text).collect() + }, + messages: request.messages.len(), + } + } +} + +/// The text one message puts on the wire. +/// +/// The arguments and the results are included, not only the prose: a run whose +/// window is filling is filling with tool output, and a count that left it out +/// would report a small conversation on the turn that is about to overflow. +fn message_text(message: &Message) -> String { + match message { + Message::User(text) => text.clone(), + Message::Assistant { text, calls } => { + let mut out = text.clone().unwrap_or_default(); + for call in calls { + out.push_str(&call.name); + out.push_str(&call.arguments.to_string()); + } + out + } + Message::Results(results) => results.iter().map(|r| r.content.as_str()).collect(), + // Exhaustive, with no wildcard, for the reason `servers::transport` is: + // `Message` is NOT `#[non_exhaustive]`, so a variant a later io-harness + // adds breaks this build rather than being silently counted as nothing — + // which on this page would be a window that under-reports itself. + } +} + +/// The one request the session has most recently made, shared with whatever drew +/// the page. +/// +/// A handle rather than a global: `tests/` runs several sessions in one process, +/// and a process-wide slot would have them overwriting each other's window. It is +/// cheap to clone — the decorator holds one and the driver holds the other — and +/// the lock is held for the length of one `clone`, never across an await. +#[derive(Debug, Clone, Default)] +pub struct Seen(Arc>>); + +impl Seen { + /// Keep this request as the newest one. + /// + /// **Newest and not first.** A turn makes one completion per step and the + /// window is what the *next* one would carry, so a page drawn after a turn + /// should describe the request that ended it. Keeping the first would + /// describe a window several tool results out of date. + pub fn record(&self, request: &CompletionRequest) { + // `unwrap_or_else` on the poison rather than `expect`: a panic in some + // other thread must not take out the page that would explain it. + let mut slot = self.0.lock().unwrap_or_else(|e| e.into_inner()); + *slot = Some(Request::of(request)); + } + + /// The newest request, or `None` where no turn has run yet. + pub fn latest(&self) -> Option { + self.0.lock().unwrap_or_else(|e| e.into_inner()).clone() + } + + /// Forget it, for a `/clear`, a `/resume` or a rewind. + /// + /// The same hole `Status::forget_run` and `servers::Observed::forget` close: + /// per-run state that outlives the run describes a conversation the operator + /// is no longer in. + pub fn forget(&self) { + *self.0.lock().unwrap_or_else(|e| e.into_inner()) = None; + } +} + +/// One region of the request, with what it cost. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Section { + /// What it is, in the words F7 names it by. + pub label: String, + /// What it is made of — a count of files, of tools, of notes, of messages. + pub detail: String, + /// Its size, by [`estimate_tokens`] over the region's own text. + pub tokens: u64, +} + +/// The text one tool occupies on the wire. +/// +/// Name, description and schema, because all three are sent. A catalogue counted +/// by name alone reports a few dozen tokens for a toolbox that costs thousands, +/// and the schema is where nearly all of it is. +fn tool_text(tool: &ToolSpec) -> String { + format!("{}\n{}\n{}", tool.name, tool.description, tool.parameters) +} + +/// The server a namespaced tool came from. +/// +/// Resolved against the ids the contract configured rather than by splitting on +/// `__`, because a server id may itself contain the separator and a split would +/// then name a server nobody configured. A prefixed tool matching no configured +/// id keeps its full name as the group: it is still an MCP tool, and inventing an +/// id for it would be worse than saying the one on the wire. +fn server_of(tool: &str, contract: &TaskContract) -> String { + let bare = match tool.strip_prefix(MCP_TOOL_PREFIX) { + Some(bare) => bare, + None => return String::new(), + }; + for server in &contract.mcp { + if bare.strip_prefix(&server.id).is_some_and(|r| r.starts_with("__")) { + return server.id.clone(); + } + } + bare.split_once("__") + .map(|(server, _)| server.to_string()) + .unwrap_or_else(|| bare.to_string()) +} + +/// The region of `haystack` occupied by the repository's instructions. +/// +/// `instructions_section` joins them with a blank line and drops them verbatim +/// into the system block, so the join *is* the needle. `None` means this request +/// did not carry them — a contract with no instructions, or a system prompt the +/// operator replaced — and the page says so rather than reporting a zero that +/// looks like an empty file. +fn instructions_in<'a>(haystack: &'a str, instructions: &[String]) -> Option<&'a str> { + if instructions.is_empty() { + return None; + } + let needle = instructions.join("\n\n"); + let at = haystack.find(&needle)?; + Some(&haystack[at..at + needle.len()]) +} + +/// The region of `haystack` occupied by the recalled memory block. +/// +/// Located by io-harness's own head sentence and ended at the first line that is +/// neither a head nor a note. `render_notes` writes exactly three shapes of line +/// — the two heads and `- key: value (step n)`, with an elision line that is +/// also a `- ` — and the observations that follow it are emitted whole or as +/// bracketed stubs, so the first line that is not one of those is where the block +/// stops. +/// +// ponytail: a line-shape scan, not a parser. Its ceiling is an observation whose +// first line begins with "- ", which would be counted as memory rather than as +// conversation; both are inside the same request, so the total is unaffected and +// only the split between two rows moves. The upgrade path is an io-harness +// accessor for `Assembled`, which is what would remove the scan entirely. +fn memory_in(haystack: &str) -> Option<&str> { + let at = [MEMORY_HEAD, MEMORY_GLOBAL_HEAD] + .iter() + .filter_map(|head| haystack.find(head)) + .min()?; + let mut end = at; + for line in haystack[at..].split_inclusive('\n') { + let trimmed = line.trim_end_matches('\n'); + let is_block = trimmed.starts_with(MEMORY_HEAD) + || trimmed.starts_with(MEMORY_GLOBAL_HEAD) + || trimmed.starts_with("- "); + if !is_block { + break; + } + end += line.len(); + } + Some(&haystack[at..end]) +} + +/// The six sections of one request, in the order the page draws them. +/// +/// Every one is a region of `seen` and no two overlap, so +/// [`total`] is a statement about the request rather than a sum of unrelated +/// numbers. The MCP half is one row per server the request actually offered tools +/// from — plus one row saying so when it offered none, because a section that +/// disappears when it is empty is a section a reader cannot tell from a section +/// that was never drawn. +pub fn sections(seen: &Request, contract: &TaskContract) -> Vec
{ + let mut out = Vec::new(); + + // 1 and 2 — the system block, split at the guidance io-cli put in it. + let guidance = instructions_in(&seen.system, &contract.instructions); + let guidance_tokens = guidance.map(estimate_tokens).unwrap_or(0); + out.push(Section { + label: "system block".into(), + detail: "what io-harness composed for this turn".into(), + tokens: estimate_tokens(&seen.system).saturating_sub(guidance_tokens), + }); + out.push(Section { + label: "repository instructions".into(), + detail: match guidance { + Some(_) => format!( + "{} file(s), carried inside the system block", + contract.instructions.len() + ), + None => "none in this request".into(), + }, + tokens: guidance_tokens, + }); + + // 3 and 4 — the catalogue, split at the MCP prefix. + let (from_mcp, workspace): (Vec<&ToolSpec>, Vec<&ToolSpec>) = seen + .tools + .iter() + .partition(|tool| tool.name.starts_with(MCP_TOOL_PREFIX)); + out.push(Section { + label: "tool catalogue".into(), + detail: format!("{} tool(s) offered", workspace.len()), + tokens: workspace + .iter() + .map(|tool| estimate_tokens(&tool_text(tool))) + .sum(), + }); + // Grouped in the order the request listed them, so the page reads in the + // order the model does. A `BTreeMap` would sort them, which is a second + // ordering of a list that already has one. + let mut servers: Vec<(String, Vec<&ToolSpec>)> = Vec::new(); + for tool in from_mcp.iter().copied() { + let id = server_of(&tool.name, contract); + match servers.iter_mut().find(|(name, _)| *name == id) { + Some((_, list)) => list.push(tool), + None => servers.push((id, vec![tool])), + } + } + if servers.is_empty() { + out.push(Section { + label: "mcp tools".into(), + detail: "no server offered a tool to this request".into(), + tokens: 0, + }); + } + for (id, tools) in &servers { + out.push(Section { + label: format!("mcp {id}"), + detail: format!("{} tool(s) offered", tools.len()), + tokens: tools + .iter() + .map(|tool| estimate_tokens(&tool_text(tool))) + .sum(), + }); + } + + // 5 and 6 — the conversation, split at the memory block. + let memory = memory_in(&seen.conversation); + let memory_tokens = memory.map(estimate_tokens).unwrap_or(0); + out.push(Section { + label: "recalled memory".into(), + detail: match memory { + // Counted off the block rather than off `Store::memory_recalls`: the + // page is about what the request carried, and the store also holds + // the notes a fit elided. + Some(block) => format!( + "{} note(s) carried", + block.lines().filter(|l| l.starts_with("- ")).count() + ), + None => "no note was recalled into this request".into(), + }, + tokens: memory_tokens, + }); + out.push(Section { + label: "conversation".into(), + detail: match seen.messages { + 0 => "one flat turn".into(), + n => format!("{n} message(s)"), + }, + tokens: estimate_tokens(&seen.conversation).saturating_sub(memory_tokens), + }); + + out +} + +/// What the sections come to. +pub fn total(sections: &[Section]) -> u64 { + sections.iter().map(|section| section.tokens).sum() +} + +/// The window this contract declares, in tokens. +/// +/// `remaining` is what is left of `[run] max_tokens` for this turn, which is what +/// makes the ceiling move: with a run budget the assembled section takes a share +/// of what is *unspent*, so a run running low reports a smaller window rather +/// than the flat maximum it can no longer afford. +pub fn window(contract: &TaskContract, remaining: Option) -> u64 { + contract.context.effective_tokens(remaining) +} + +/// The page, committed into the scrollback. +/// +/// A page and never a modal: the viewport is four rows and cannot grow, and the +/// terminal's own search, selection and copy already work on everything above it +/// — the same answer `/status` and `/expand` give. `seen` is `None` before the +/// first turn, and that is said in a sentence rather than drawn as an empty +/// table: a window nothing has been sent through has no contents, and zeroes +/// would read as an empty one. +pub fn committed( + seen: Option<&Request>, + contract: &TaskContract, + remaining: Option, + theme: &Theme, + width: u16, +) -> Vec> { + let rule = theme.glyphs.rule; + let dash = theme.glyphs.dash; + let mut lines = vec![Line::from(Span::styled( + format!("{rule}{rule}{rule} context"), + theme.style(Tone::Accent), + ))]; + + let room = width as usize; + // Every row folded rather than fitted, for the reason `crate::status`'s page + // is: this owns as many rows as it needs, and the characters a fitter would + // drop are the tool names and the numbers. + let mut push = |text: String, tone: Tone| { + for row in wrapped(&text, room) { + lines.push(Line::from(Span::styled(row, theme.style(tone)))); + } + }; + + match seen { + None => push( + format!( + "nothing has been sent yet {dash} the window is read from the request \ + that carries a turn, so /context answers once one has run" + ), + Tone::Muted, + ), + Some(seen) => { + let sections = sections(seen, contract); + for section in §ions { + push( + format!( + "{}: {} tokens {dash} {}", + section.label, section.tokens, section.detail + ), + Tone::Normal, + ); + } + let total = total(§ions); + let window = window(contract, remaining); + push( + format!( + "total: {total} tokens of {window} {dash} the window this contract declares" + ), + Tone::Normal, + ); + + // The catalogue itself, named. The counts above say how much it + // costs; this says what the model was actually handed, which is the + // half an operator cannot get from anywhere else — a tool here that + // io-cli never registered is a tool that arrived from a server or a + // plugin, and it is on the wire either way. + if seen.tools.is_empty() { + push("offered: no tool at all".into(), Tone::Muted); + } else { + let names: Vec<&str> = seen.tools.iter().map(|t| t.name.as_str()).collect(); + push(format!("offered: {}", names.join(", ")), Tone::Muted); + } + } + } + + lines.push(Line::from(Span::styled( + format!("{rule}{rule}{rule} context ends"), + theme.style(Tone::Accent), + ))); + lines +} + +/// `text` as rows no wider than `width`, indented two then four. +/// +/// **Folded and never fitted**, for the reason `crate::status`'s own helper is: a +/// committed surface owns as many rows as it needs, and the characters a fitter +/// would drop here are tool names — which is to say the answer. A word longer +/// than its row is split rather than allowed to overflow, so eighty columns holds. +fn wrapped(text: &str, width: usize) -> Vec { + let mut rows: Vec = Vec::new(); + let mut indent = 2usize; + let mut row = " ".repeat(indent); + let mut used = 0usize; + for word in text.split_whitespace() { + let mut word = word; + loop { + // At least one cell, so a terminal narrower than the indent still + // makes progress instead of looping forever. + let room = width.saturating_sub(indent).max(1); + let space = usize::from(used > 0); + let length = word.chars().count(); + if used + space + length <= room { + if space == 1 { + row.push(' '); + } + row.push_str(word); + used += space + length; + break; + } + if used > 0 { + rows.push(std::mem::take(&mut row)); + indent = 4; + row = " ".repeat(indent); + used = 0; + continue; + } + let head: String = word.chars().take(room).collect(); + word = &word[head.len()..]; + row.push_str(&head); + rows.push(std::mem::take(&mut row)); + indent = 4; + row = " ".repeat(indent); + } + } + if used > 0 || rows.is_empty() { + rows.push(row); + } + rows +} diff --git a/src/lib.rs b/src/lib.rs index a8d2029..794376a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,6 +34,7 @@ pub mod commands; pub mod complete; pub mod composer; pub mod configure; +pub mod context; pub mod contract; pub mod diff; pub mod edit; diff --git a/src/provider.rs b/src/provider.rs index d260f8c..e2c8186 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -17,9 +17,13 @@ //! a preset that can fail. Making every arm fallible keeps one signature and lets //! a failed model switch report itself instead of ending the session. -use io_harness::{Anthropic, Auth, Compatible, OpenAi, OpenRouter, Provider, ProviderSpec}; +use io_harness::{ + Anthropic, Auth, Compatible, CompletionRequest, CompletionResponse, ModelInfo, OpenAi, + OpenRouter, PromptFamily, Provider, ProviderSpec, ToolCall, +}; use crate::cli::FromEnv; +use crate::context::Seen; /// What a caller does once there is a provider to build. /// @@ -118,6 +122,126 @@ pub async fn build( } } +/// A provider that keeps a copy of every request on the way past. +/// +/// **The only way io-cli can say what is in the context window.** io-harness +/// enumerates none of it — `run::prompts::compose` and `workspace_tools()` are +/// both `pub(super)`, and `EventKind::PromptComposed` carries a byte count and no +/// text — so the alternative to reading the wire is io-cli reconstructing a +/// prompt it did not compose. This does not reconstruct anything: the request +/// that goes out is the request that is reported, which is why the catalogue on +/// the page includes tools this crate never registered and could not have named. +/// +/// **It wraps and never constructs**, which is what keeps it on the right side of +/// `tests/provider.rs`'s one-construction-site rule: the four constructors are +/// still written once, in [`build`], and this newtype only decorates whatever +/// they made. Both entry points reach it through the same maker, so the headless +/// path cannot drift from the interactive one. +/// +/// Every method delegates. That is not boilerplate to be trimmed: the trait has +/// eight defaulted methods and a default that fired here would change behaviour +/// silently — `name()` would record `"provider"` in the trace instead of the +/// vendor, `prompt_family()` would re-derive a family from a hint this type does +/// not have, `accepts_images()` would refuse an attachment the provider accepts, +/// and `complete_streaming` would deliver a whole answer in one piece. A +/// decorator that observes must be invisible in every other respect. +/// +/// The recording is a lock taken and released inside one statement, before the +/// future is returned — so nothing is held across an await, and the delegating +/// methods hand back the inner future itself rather than wrapping it in a state +/// machine of their own. +pub struct Watched

{ + inner: P, + seen: Seen, +} + +impl

Watched

{ + /// Wrap `inner`, reporting into `seen`. + pub fn new(inner: P, seen: Seen) -> Self { + Self { inner, seen } + } +} + +impl Provider for Watched

{ + fn complete( + &self, + request: CompletionRequest, + ) -> impl std::future::Future> + Send { + self.seen.record(&request); + self.inner.complete(request) + } + + fn complete_streaming( + &self, + request: CompletionRequest, + on_token: &(dyn Fn(&str) + Send + Sync), + ) -> impl std::future::Future> { + self.seen.record(&request); + self.inner.complete_streaming(request, on_token) + } + + fn complete_streaming_calls( + &self, + request: CompletionRequest, + on_token: &(dyn Fn(&str) + Send + Sync), + on_call: &(dyn Fn(usize, &ToolCall) + Send + Sync), + ) -> impl std::future::Future> { + self.seen.record(&request); + self.inner.complete_streaming_calls(request, on_token, on_call) + } + + fn models(&self) -> impl std::future::Future>> + Send { + self.inner.models() + } + + fn reachable(&self) -> impl std::future::Future> + Send { + self.inner.reachable() + } + + fn model_hint(&self) -> Option<&str> { + self.inner.model_hint() + } + + fn name(&self) -> &str { + self.inner.name() + } + + fn prompt_family(&self) -> PromptFamily { + self.inner.prompt_family() + } + + fn accepts_images(&self) -> bool { + self.inner.accepts_images() + } + + fn endpoint(&self) -> Option<&str> { + self.inner.endpoint() + } + + fn endpoints(&self) -> Vec<&str> { + self.inner.endpoints() + } + + fn last_served(&self) -> Option { + self.inner.last_served() + } +} + +/// The same maker, making watched providers. +/// +/// A maker in and a maker out, rather than a provider in and a provider out, +/// because a session builds a provider more than once: `/model` calls the maker +/// again on every switch. Wrapping the *maker* means one line at the top of the +/// driver covers the switch too, and there is no second place for a session to +/// end up holding an unwatched provider — which would show as a `/context` page +/// that quietly stopped updating after the first model change. +pub fn watching( + make: impl Fn(&str) -> Result, + seen: Seen, +) -> impl Fn(&str) -> Result, String> { + move |name| make(name).map(|inner| Watched::new(inner, seen.clone())) +} + /// A spec built from the environment rather than from a configuration file. /// /// `key` and `model` are passed in rather than read here so that the decision diff --git a/tests/commands.rs b/tests/commands.rs index 485df66..2114521 100644 --- a/tests/commands.rs +++ b/tests/commands.rs @@ -42,6 +42,9 @@ fn the_commands_are_the_commands() { // for, and a key is cheap to add later and expensive to take back // once it is in anybody's fingers. "/status", + // 0.17.0 — `/status` says how full the window is and this says what + // is in it. Beside it in the table for that reason. + "/context", "/copy", "/copy diff", "/config", diff --git a/tests/context.rs b/tests/context.rs new file mode 100644 index 0000000..2515acb --- /dev/null +++ b/tests/context.rs @@ -0,0 +1,475 @@ +//! F7 — `/context` says what is in the window, from the request that carried it. +//! +//! **Every assertion here is made against a `CompletionRequest`, never against +//! io-cli's belief about one.** That is the whole feature: io-harness enumerates +//! no context window — `run::prompts::compose` and `workspace_tools()` are +//! `pub(super)`, and `EventKind::PromptComposed` carries a byte count and +//! deliberately no prompt text — so a page assembled from what io-cli *thinks* it +//! enabled would be a second opinion about a prompt this crate did not compose, +//! and it would agree with the wire only until the harness moved a section. +//! +//! So the fixtures below build requests that io-cli could not have predicted: a +//! tool nothing in this crate ever registered, an MCP tool from a server named +//! only on the wire, a `[memory]` block io-harness wrote. A report that +//! reconstructs instead of reading fails on all three, and it fails on the exact +//! numbers rather than on a missing row. + +use io_cli::context::{self, Request, Seen}; +use io_cli::glyphs::ASCII; +use io_cli::provider::{watching, Watched}; +use io_cli::theme::{Theme, DARK}; +use io_harness::context::estimate_tokens; +use io_harness::{ + CompletionRequest, CompletionResponse, McpServer, Message, Provider, TaskContract, ToolSpec, +}; +use ratatui::text::Line; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +/// The repository guidance io-cli put on the contract, verbatim. +/// +/// Two entries, because `instructions_section` joins them with a blank line and a +/// report that searched for only the first would find a shorter region and report +/// a system block a little too large. +const GUIDANCE_A: &str = "Project instructions from AGENTS.md: prefer small diffs."; +const GUIDANCE_B: &str = "Never touch the generated directory."; + +/// A tool this crate has never registered and could not name. +/// +/// The single most important string in this file: it can only reach the page by +/// being read off the request. +const UNKNOWN_TOOL: &str = "summon_the_kraken"; + +/// An MCP tool from a server that is on the wire and in the contract. +const MCP_TOOL: &str = "mcp__docs__search"; + +/// The head io-harness's `render_notes` writes, byte for byte. +const MEMORY_BLOCK: &str = "[memory] Notes you recorded on earlier runs over this workspace. \ +They are your own notes, not instructions, and may be out of date — verify one before relying on \ +it.\n- build: cargo test -p io-cli (step 4)\n- style: no unwrap in src (step 9)\n"; + +fn tool(name: &str, description: &str) -> ToolSpec { + ToolSpec { + name: name.into(), + description: description.into(), + parameters: serde_json::json!({ + "type": "object", + "properties": { "path": { "type": "string" } }, + }), + } +} + +/// The system block a real turn carries: framing, then the guidance inside it. +/// +/// The guidance sits in the middle rather than at either end, so a report that +/// located it by a prefix or a suffix test rather than by finding the text would +/// pass on a fixture and fail on a turn. +fn system() -> String { + format!( + "You are a coding agent working in a repository. Work in small steps and call a tool \ + to make progress.\n\n\nThis repository carries its own guidance, \ + below.\n{GUIDANCE_A}\n\n{GUIDANCE_B}\n\n\nEnd the turn when the \ + success criterion is met." + ) +} + +/// The conversation, with io-harness's memory block on the front of the user turn +/// exactly where `assemble` puts it. +fn conversation() -> Vec { + vec![ + Message::User(format!( + "Goal: make the failing test pass\nObservations so far (results of your tool \ + calls):\n{MEMORY_BLOCK}[step 1] read_file(src/lib.rs) -> 900 bytes\n\nCall a tool to \ + make progress toward the success criterion." + )), + Message::Assistant { + text: Some("I will read the test first.".into()), + calls: Vec::new(), + }, + ] +} + +/// One request as a turn would actually make it. +fn request() -> CompletionRequest { + CompletionRequest { + system: system(), + user: "the flat form, which a conversational request does not use".into(), + messages: conversation(), + tools: vec![ + tool("read_file", "Read a file from the workspace."), + tool(UNKNOWN_TOOL, "A tool io-cli has never heard of."), + tool(MCP_TOOL, "Search the documentation server."), + ], + ..Default::default() + } +} + +/// The contract the session would build: the guidance it discovered, and the +/// server it configured. +fn contract() -> TaskContract { + TaskContract::workspace("make the failing test pass", "/repo") + .with_instruction(GUIDANCE_A) + .with_instruction(GUIDANCE_B) + .with_mcp([McpServer::stdio("docs", "mcp-docs")]) +} + +/// The theme the sweeps draw in: the ordinary palette, ASCII glyph set. +fn ascii() -> Theme { + DARK.with_glyphs(ASCII) +} + +/// One rendered line as a reader sees it. +fn row(line: &Line<'_>) -> String { + line.spans.iter().map(|s| s.content.as_ref()).collect() +} + +fn drawn(lines: &[Line<'_>]) -> Vec { + lines.iter().map(row).collect() +} + +/// The section carrying `label`, or a failure naming what there was. +fn section<'a>(sections: &'a [context::Section], label: &str) -> &'a context::Section { + sections + .iter() + .find(|s| s.label == label) + .unwrap_or_else(|| { + panic!( + "no section labelled {label:?}; there were {:?}", + sections.iter().map(|s| &s.label).collect::>() + ) + }) +} + +// --------------------------------------------------------------------------- +// The mechanism: the request is read off the wire +// --------------------------------------------------------------------------- + +/// A provider that answers nothing and records what it was asked. +struct Silent; + +impl Provider for Silent { + async fn complete(&self, _request: CompletionRequest) -> io_harness::Result { + Ok(CompletionResponse { + text: Some("done".into()), + ..Default::default() + }) + } +} + +#[tokio::test] +async fn f7_the_window_is_read_from_the_request_that_carried_the_turn() { + let seen = Seen::default(); + assert!( + seen.latest().is_none(), + "before a turn has run there is nothing to report, and a zeroed page \ + would read as an empty window rather than as an unasked one", + ); + + let watched = Watched::new(Silent, seen.clone()); + let answer = watched.complete(request()).await.expect("the mock answers"); + assert_eq!(answer.text.as_deref(), Some("done"), "the decorator delegates"); + + let carried = seen.latest().expect("the request went past"); + assert_eq!(carried.system, system(), "the system block, byte for byte"); + assert_eq!(carried.messages, 2); + assert_eq!( + carried.tools.iter().map(|t| t.name.as_str()).collect::>(), + vec!["read_file", UNKNOWN_TOOL, MCP_TOOL], + "the catalogue as offered, in the order it was offered", + ); +} + +#[tokio::test] +async fn f7_a_model_switch_keeps_reporting_because_the_maker_is_what_is_wrapped() { + // `/model` calls the maker again, so a decorator applied to a provider rather + // than to a maker would leave the session reporting a window that stopped + // updating at the first switch — a page that is wrong and still draws. + let seen = Seen::default(); + let make = watching( + |_name: &str| -> Result { Ok(Silent) }, + seen.clone(), + ); + + let first = make("model-a").expect("the maker makes"); + first.complete(request()).await.expect("the mock answers"); + assert!(seen.latest().is_some()); + + seen.forget(); + let switched = make("model-b").expect("the maker makes again"); + switched + .complete(request()) + .await + .expect("the mock answers"); + assert!( + seen.latest().is_some(), + "the provider built by the second call to the maker is watched too", + ); +} + +// --------------------------------------------------------------------------- +// The six sections, their counts, and the sum +// --------------------------------------------------------------------------- + +#[test] +fn f7_every_section_is_named_and_carries_a_count() { + let seen = Request::of(&request()); + let sections = context::sections(&seen, &contract()); + + for label in [ + "system block", + "repository instructions", + "tool catalogue", + "mcp docs", + "recalled memory", + "conversation", + ] { + let section = section(§ions, label); + assert!( + section.tokens > 0, + "{label} is in this request and should not read as empty: {section:?}", + ); + } +} + +#[test] +fn f7_the_mcp_row_is_the_server_the_request_offered_tools_from() { + // Grouped by the id the contract configured rather than by splitting the + // namespaced name on `__`, because a server id may contain the separator. + let seen = Request::of(&request()); + let sections = context::sections(&seen, &contract()); + let mcp = section(§ions, "mcp docs"); + assert_eq!(mcp.detail, "1 tool(s) offered"); + assert_eq!( + mcp.tokens, + estimate_tokens(&format!( + "{}\n{}\n{}", + MCP_TOOL, + "Search the documentation server.", + tool(MCP_TOOL, "").parameters + )), + "counted over the text the tool actually occupies: name, description and schema", + ); + + // And a request with no MCP tool still draws the section, because a row that + // vanishes when it is empty cannot be told from one that was never drawn. + let mut bare = request(); + bare.tools.retain(|t| t.name != MCP_TOOL); + let sections = context::sections(&Request::of(&bare), &contract()); + let none = section(§ions, "mcp tools"); + assert_eq!(none.tokens, 0); + assert_eq!(none.detail, "no server offered a tool to this request"); +} + +#[test] +fn f7_the_counts_are_io_harness_estimate_tokens_over_the_real_strings() { + let seen = Request::of(&request()); + let sections = context::sections(&seen, &contract()); + + // The guidance, exactly as `instructions_section` joins it. + let guidance = format!("{GUIDANCE_A}\n\n{GUIDANCE_B}"); + assert!( + seen.system.contains(&guidance), + "the fixture must carry the guidance the way the harness writes it", + ); + assert_eq!( + section(§ions, "repository instructions").tokens, + estimate_tokens(&guidance), + ); + + // The system block is the rest of it, which is what keeps the two from + // counting the same bytes twice. + assert_eq!( + section(§ions, "system block").tokens, + estimate_tokens(&seen.system) - estimate_tokens(&guidance), + ); + + // The memory block, located by io-harness's own head sentence. + assert_eq!( + section(§ions, "recalled memory").tokens, + estimate_tokens(MEMORY_BLOCK), + ); + assert_eq!( + section(§ions, "recalled memory").detail, + "2 note(s) carried", + ); + + // And the conversation is what is left of it. + assert_eq!( + section(§ions, "conversation").tokens, + estimate_tokens(&seen.conversation) - estimate_tokens(MEMORY_BLOCK), + ); + + // Nothing here is io-cli's own heuristic. Asserted directly, because a + // hand-rolled estimator agreeing to within a few percent would pass every + // assertion above and still put a number beside a ceiling measured in a + // different unit. + assert_eq!( + estimate_tokens("abcd"), + 1, + "the estimator under test is io-harness's own", + ); +} + +#[test] +fn f7_the_sections_sum_to_the_total_and_the_total_is_stated_against_the_window() { + let seen = Request::of(&request()); + let contract = contract(); + let sections = context::sections(&seen, &contract); + let total = context::total(§ions); + + assert_eq!(total, sections.iter().map(|s| s.tokens).sum::()); + + // The denominator is the contract's own budget, not `ContextBudget::default`: + // an operator who tightened `[context]` must see the ceiling they set. + assert_eq!(context::window(&contract, None), 24_000); + let tightened = TaskContract::workspace("g", "/repo") + .with_context_budget(io_harness::ContextBudget { + max_tokens: 8_000, + share: 0.25, + }); + assert_eq!(context::window(&tightened, None), 8_000); + assert_eq!( + context::window(&tightened, Some(20_000)), + 5_000, + "with a run budget the window is a share of what is left", + ); +} + +// --------------------------------------------------------------------------- +// The catalogue is the wire's, not io-cli's +// --------------------------------------------------------------------------- + +#[test] +fn f7_the_catalogue_names_a_tool_only_the_request_knew_about() { + // io-cli registers no tool called this and has no list it could come from. + // If it is on the page, the page was read from the request. + let sources: String = std::fs::read_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/src")) + .expect("src is readable") + .flatten() + .filter(|e| e.path().extension().is_some_and(|x| x == "rs")) + .map(|e| std::fs::read_to_string(e.path()).unwrap_or_default()) + .collect(); + assert!( + !sources.contains(UNKNOWN_TOOL), + "the fixture only means something while no source file names this tool", + ); + + let page = drawn(&context::committed( + Some(&Request::of(&request())), + &contract(), + None, + &ascii(), + 80, + )); + assert!( + page.iter().any(|row| row.contains(UNKNOWN_TOOL)), + "the tool catalogue must list what was offered, including what io-cli \ + never registered: {page:#?}", + ); + assert!( + page.iter().any(|row| row.contains(MCP_TOOL)), + "and the MCP tools with them: {page:#?}", + ); +} + +// --------------------------------------------------------------------------- +// The sabotage +// --------------------------------------------------------------------------- + +/// **Kills the sabotage.** Report the system block from `PromptComposed`'s byte +/// count instead of the request's own text. +/// +/// It is the shape most likely to be written, because the byte count is the one +/// number io-harness *does* hand out for the prompt — and it survives every +/// weaker assertion: the row is present, the number is large and plausible, and +/// nothing about the page looks broken. What it cannot survive is arithmetic. A +/// count of bytes presented as tokens is roughly four times the section it names, +/// so the rows no longer add up to the total printed beside them, and the total +/// no longer means anything against the window it is stated over. +#[test] +fn f7_the_system_block_is_tokens_of_its_own_text_and_not_a_byte_count() { + let seen = Request::of(&request()); + let contract = contract(); + let sections = context::sections(&seen, &contract); + let block = section(§ions, "system block"); + + let bytes = seen.system.len() as u64; + assert!( + bytes > block.tokens * 2, + "the fixture is only a trap if the two numbers are far apart: \ + {bytes} bytes against {} tokens", + block.tokens, + ); + assert_ne!( + block.tokens, bytes, + "the system block is measured with `io_harness::context::estimate_tokens` \ + over the request's own text; a byte count is a different unit wearing the \ + same label", + ); + + // The arithmetic, on the rendered page rather than on the values behind it — + // which is where an operator does this subtraction and where a sabotaged row + // stops reconciling. + let page = drawn(&context::committed(Some(&seen), &contract, None, &ascii(), 80)); + let counted: u64 = page + .iter() + .filter_map(|row| row.split_once(": ")) + .filter(|(label, _)| !label.trim().starts_with("total")) + .filter_map(|(_, rest)| rest.split_whitespace().next()) + .filter_map(|n| n.parse::().ok()) + .sum(); + let total = context::total(§ions); + assert_eq!( + counted, total, + "the numbers on the page must add up to the total printed under them: \ + {page:#?}", + ); + assert!( + page.iter() + .any(|row| row.contains(&format!("total: {total} tokens of 24000"))), + "and the total is stated against the window the contract declares: {page:#?}", + ); +} + +// --------------------------------------------------------------------------- +// The surface +// --------------------------------------------------------------------------- + +#[test] +fn f7_the_page_draws_in_ascii_and_says_so_before_a_turn_has_run() { + let empty = drawn(&context::committed(None, &contract(), None, &ascii(), 80)); + assert!( + empty.iter().any(|row| row.contains("nothing has been sent yet")), + "a window nothing has been sent through has no contents, and zeroes would \ + read as an empty one: {empty:#?}", + ); + + let page = drawn(&context::committed( + Some(&Request::of(&request())), + &contract(), + None, + &ascii(), + 80, + )); + let all = empty.join("\n") + "\n" + &page.join("\n"); + if let Some(bad) = all.chars().find(|c| !c.is_ascii()) { + panic!( + "the /context page drew {bad:?} (U+{:04X}) under the ASCII set; every \ + glyph must have an ASCII form.\n{all}", + bad as u32, + ); + } + + // Committed upward as a page, and every row inside the terminal it was given. + assert!(page.first().is_some_and(|row| row.contains("context"))); + assert!(page.last().is_some_and(|row| row.contains("context ends"))); + for row in &page { + assert!( + row.chars().count() <= 80, + "a committed row is folded to the width it was given, never allowed to \ + be wrapped somewhere nothing chose: {row:?}", + ); + } +} diff --git a/tests/contract.rs b/tests/contract.rs index ec1e3c2..abd13da 100644 --- a/tests/contract.rs +++ b/tests/contract.rs @@ -747,11 +747,17 @@ fn f6_both_arms_are_handed_one_contract() { 1, "one contract is built per turn, not one per arm", ); + // TWO reading sites from 0.17.0, and the number is the point rather than a + // tolerance: `/status` reports the configured rosters and `/context` reports + // the window the next turn would run under. Both READ a contract and neither + // takes a turn with it, which is why they share a binding name that is not + // `contract` — the assertion above finds the turn's builder by that name, and + // a page that bound it would hand that assertion the wrong argument list. assert_eq!( text.matches("let reading = io_cli::contract::session(") .count(), - 1, - "`/status` reads one contract to report the configured rosters, and builds no turn", + 2, + "`/status` and `/context` each read one contract to report with, and neither builds a turn", ); assert_eq!( text.matches("let opening = io_cli::contract::session(") @@ -761,8 +767,9 @@ fn f6_both_arms_are_handed_one_contract() { ); assert_eq!( text.matches("io_cli::contract::session(").count(), - 3, - "the turn's contract and the two readers are the only three, so a fourth is a new arm", + 4, + "the turn's contract, the startup reading and the two reporting pages are the only four, \ + so a fifth is a new arm", ); assert!( !text.contains("with_responder") && !text.contains("with_plan_gate"), diff --git a/tests/dependencies.rs b/tests/dependencies.rs index 59fa5c6..be27a86 100644 --- a/tests/dependencies.rs +++ b/tests/dependencies.rs @@ -144,11 +144,49 @@ fn sources() -> Vec<(PathBuf, String)> { fn f7_no_source_file_loops_over_provider_responses() { // The agent loop is io-harness's. This crate calls a provider exactly once — // the wizard's verification handshake — and never in a loop; everything else - // goes through `Session::turn_steered`, which IS the harness's loop. + // goes through the harness's own steered turn entry points, which ARE the + // loop. + // + // **0.17.0 amends this gate rather than relaxing it.** `/context` can only + // say what is in the model's window by reading the request on its way to the + // provider, so `src/provider.rs` gained `Watched`, which implements the trait + // by handing every call straight to the provider it wraps. Those forwards are + // provider calls by the letter of the count and are the opposite of a second + // agent loop by its intent — so they are exempted BY PATH, and a new + // assertion below keeps the exemption a boundary: a forward must hand the + // call to the inner provider and must never look at what comes back. The + // shape is 0.7.0's amendment of the spawn ban, for the same reason. + let delegating = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("src") + .join("provider.rs"); + let forwarding = std::fs::read_to_string(&delegating).expect("the provider module"); + let forwards = forwarding.matches("self.inner.complete").count(); + assert!( + forwards >= 3, + "the decorator forwards every completion method it implements; found {forwards}", + ); + // A forward hands the request on and returns what the inner provider + // returned. If this module ever reads a response — a token, a tool call, a + // message — it has stopped decorating and started interpreting, which is the + // first half of an agent loop wherever it is written. + for interpreting in [".choices", ".tool_calls", "match response", "let response ="] { + assert!( + !forwarding.contains(interpreting), + "src/provider.rs reads a provider response (`{interpreting}`) — a decorator \ + records the REQUEST and returns the answer untouched", + ); + } + let mut calls = Vec::new(); for (path, text) in sources() { - let count = - text.matches(".complete(").count() + text.matches(".complete_streaming(").count(); + // The forwards are subtracted rather than the file being skipped, so the + // decorator is still held to the no-top-level-loop rule below — an + // exemption that took a module out of the sweep entirely would exempt it + // from the half of this gate that matters most. + let forwarded = if path == delegating { forwards } else { 0 }; + let count = (text.matches(".complete(").count() + + text.matches(".complete_streaming(").count()) + .saturating_sub(forwarded); if count == 0 { continue; } diff --git a/tests/palette.rs b/tests/palette.rs index b4254ce..4bb5b11 100644 --- a/tests/palette.rs +++ b/tests/palette.rs @@ -307,12 +307,12 @@ fn f1_an_exact_name_outranks_a_prefix_which_outranks_a_scattered_match() { #[test] fn f1_equal_scores_keep_the_first_row_still_between_keystrokes() { - // `c` matches SIX rows since 0.16.0: `copy`, `copy diff`, `contain`, `clear` - // and `config` all begin with it, and `mcp` merely holds one — which is the - // point of the arm, since a scattered match ranks below every prefix. - // `attach`, which also merely held one, stopped being a command in 0.13.1. - // `co` narrows to the four that begin with `co`: `clear` has no `o` after its - // `c`, and neither does `mcp`. + // `c` matches SEVEN rows since 0.17.0: `copy`, `copy diff`, `contain`, + // `clear`, `config` and `context` all begin with it, and `mcp` merely holds + // one — which is the point of the arm, since a scattered match ranks below + // every prefix. `attach`, which also merely held one, stopped being a command + // in 0.13.1. `co` narrows to the five that begin with `co`: `clear` has no + // `o` after its `c`, and neither does `mcp`. // The three score the same and the tie-break is the order they were handed // in. The defect: an unstable sort swaps them on a keystroke that did not // change the result, and `Enter` takes a row nobody chose. @@ -323,14 +323,14 @@ fn f1_equal_scores_keep_the_first_row_still_between_keystrokes() { // quietly stopped happening. let mut picker = palette(); type_at(&mut picker, "c"); - assert_eq!(picker.matching(), 6); + assert_eq!(picker.matching(), 7); // `clear` and not `copy` since the list became grouped: the tie-break is the // order the rows were handed in, and the session group now leads. The // PROPERTY is unchanged and is what this test is about — the marked row must // not move on a keystroke that did not change the result. assert_eq!(marked(&picker), "clear"); type_at(&mut picker, "o"); - assert_eq!(picker.matching(), 4); + assert_eq!(picker.matching(), 5); assert_eq!( marked(&picker), "contain", From 029e270fad4ee36f218ca0e8198db15053cd2efa Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Wed, 26 Aug 2026 08:51:14 +0530 Subject: [PATCH 05/12] feat(steer): send a queued line into the turn that is already running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit io-harness 0.67.0 opened the two entry points that take the caller's contract AND a steer inbox on one call. Until then a turn could carry a contract or be steered and never both, which is why 0.11.0 dropped steering rather than an oversight. Both arms now take `_steered`, positionally identical to what they took before plus the inbox. The inbox is a parameter of the harness's drive call and not a field of `TaskContract`, so the contract builder is untouched and the gate asserting an unconfigured session reproduces the harness's own default contract is not at risk. `/steer` is a word rather than a default, and the reason is that a delivered steer emits no event this interface can draw — the harness records it in the run's trace, not on the observer stream. A line sent automatically would leave the screen with no echo of any kind, which is the shape of the keystroke this release just stopped losing, and worse, because a queue is drawable state and a steer is not. `Steer::say` also has no undo: default-send would make every note typed to oneself mid-turn an instruction to the agent. So the operator is told three things and never a fourth. The words go into the transcript, because that is the only place they survive. The footer says "sent — the turn reads it at its next step", never "delivered": `Ok` means the channel took it and cannot mean a step read it. And anything still in the inbox when the turn ends is reported as not delivered, which is the one delivery fact this interface can state. `Ctrl+C` does not move. It stays on the observer's cancellation flag rather than `Steer::interrupt`: both end in a cancelled outcome, but changing the mechanism changes which path records it, for no operator-visible gain, on the one key a configuration file is refused permission to rebind. The queue is now on screen and on the status line. The surface follows the fleet view and not the three modal overlays — those are modal because a run is blocked inside a harness callback waiting for an answer, and nothing here is blocked. It stays out of `App::modal()`, takes `Up`, `Down` and `Esc` only while open, and lets every other key through. **It takes the blank row, and that is the whole reason it is visible.** At the viewport a running turn actually holds — eight rows — the streaming tail, the activity line, the rule and the three-row footer take six and the composer keeps its floor of one. There is no spare row above the composer, so a surface drawn from that allowance would have drawn nothing on every real session while passing its tests; and the allowance is widest on SHORT terminals, so it would have appeared on those and never on tall ones. Growing the viewport costs every session a row of scrollback forever, and taking the composer's own row is what this layout has refused since 0.1.0. The blank carries nothing by its own argument, the queue carries something, the frame is the same height either way, and the blank returns when the queue empties. Where it has exactly one row that row goes to the line that runs next, with the rest counted on the end of it — a surface that says three are waiting and names none of them has spent its only row on the half of the answer the status line already carries. The depth is on the status line AND the footer, from one method, because the footer is what any terminal of seven rows or more is given — a field on the line alone is a field no operator sees, which is how 0.12.0 shipped an invisible planning field. It is not cleared by `forget_run`: the queue belongs to the session and outlives the run, and blanking the count would stop reporting prompts that still fire. Recorded as US-IO-CLI-0.17.0-I02. --- CHANGELOG.md | 62 +++++ README.md | 1 + src/app.rs | 140 ++++++++++- src/bridge.rs | 45 ++-- src/commands.rs | 25 +- src/context.rs | 5 +- src/lib.rs | 1 + src/main.rs | 211 +++++++++++++--- src/provider.rs | 7 +- src/queue.rs | 155 ++++++++++++ src/status.rs | 81 +++++- tests/commands.rs | 3 + tests/context.rs | 33 ++- tests/contract.rs | 9 +- tests/dependencies.rs | 7 +- tests/queue_surface.rs | 545 +++++++++++++++++++++++++++++++++++++++++ tests/servers.rs | 5 +- tests/status.rs | 95 +++++++ tests/steer.rs | 327 +++++++++++++++++++++++++ 19 files changed, 1689 insertions(+), 68 deletions(-) create mode 100644 src/queue.rs create mode 100644 tests/queue_surface.rs create mode 100644 tests/steer.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a080003..f0167ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,68 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.17.0] - 2026-08-26 + +Say something while it works, and have it land. + +**A prompt typed during a turn is kept.** Until now it was destroyed: the +composer took the keystrokes and drew them, and the `Enter` that followed reached +a branch that discarded the text while the composer that held it had already +cleared. The keystroke looked accepted and the prompt was gone. It now joins a +queue in the order it was typed, drawn above the composer, and fires when the +turn ends — one prompt per turn, each its own exchange in the scrollback and each +interruptible. A turn you stop drops what was waiting, because one press of the +stop key should not start the next three turns. + +**A queued line can be sent into the turn that is still running.** io-harness +delivers it at the next step boundary, so the step in flight completes whole and +the agent reads the correction before it chooses what to do next. That is the +difference between redirecting an agent and killing it. It is not instant and the +interface says so: a tool call in flight is not a safe place to change the +conversation out from under. + +**`Ctrl+C` means exactly what it meant.** It is the instrument for stopping, and +this is the release that stops it from being the only instrument for anything. It +still reaches the run the same way it always has, it still pre-empts an approval, +an intent question and a plan gate, and it is still refused as a rebindable +chord. + +**`/context` says what is actually in the model's window.** The system block, the +tool catalogue, the repository's instructions, each MCP server's tools, the +recalled memory and the conversation — each with the tokens it costs, summing to +a total against the window your configuration declares. It is read off the +request that carried the turn rather than estimated beside it, which is why the +catalogue includes tools io-cli never registered: it is the catalogue the model +was handed. + +**`/compact` folds the conversation when you know a long thread is finished**, +rather than when a threshold notices. It reports what folded, not what was asked +for — a request over a conversation shorter than the fold's own floor does +nothing, and with compaction turned off it does nothing, and in both cases the +line says so instead of claiming a fold. + +**`/mcp` says how many tools a server offered**, beside how many have been asked +for. Two different questions, two different numbers. A server that offered +nothing says zero; a server whose events never carried the count says nothing, +because reading a missing count as zero would report a server offering no tools +while you watched it use them. This closes the deferral 0.16.0 recorded. + +**`ctx N%` is true for the first time if you configured your own window.** Its +denominator was the crate default, so it was wrong for anyone who set `[context]` +or `[run] max_tokens`, and it was blank until the first fold — the whole period +in which the number was worth having. + +**`/status` says "answering N calls" where it said "offering N tools".** That +number has counted calls since 0.10.0; it was the one site 0.16.0's rename +missed. + +**io-harness 0.67 → 0.69.** Two breaking changes reach this crate and both land +in tests rather than in what you run: `EventKind::Mcp` gained the offered-tool +count, and `SteerInbox::pending` returns a struct rather than a tuple so that the +third thing an operator can send did not have to grow it. No dependency was +added, the feature list is unchanged, and every test that existed before this +release still passes unchanged. + ## [0.16.0] - 2026-08-25 Nobody edits `io.toml` by hand. diff --git a/README.md b/README.md index 16dd3cb..0e06d65 100644 --- a/README.md +++ b/README.md @@ -337,6 +337,7 @@ above a row that ranked there for reasons having nothing to do with it. | `/help` | this table | | `/status` | commit the whole session state into the scrollback | | `/context` | what is in the model's window, read from the request that carried the turn | +| `/steer` | send what is queued into the turn that is already running | | `/expand` | commit the last step's full detail into the scrollback | | `/fleet` | show the children this turn has spawned | | `/mcp` | the MCP servers configured, and what this session has seen of each | diff --git a/src/app.rs b/src/app.rs index b6aa4cf..b131b1c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -205,6 +205,21 @@ pub struct App { /// that outlived the terminal it was typed into would come back as a turn /// nobody asked for, against a conversation that had moved on without it. prompts: Vec, + /// Whether the surface that draws `prompts` has been shut by the operator. + /// + /// **Held rather than derived, and it is `Esc` that makes it a field.** The + /// surface opens on its own — see [`App::queue_prompt`] — so "is it open" is + /// almost the same question as "is anything queued", and it would be exactly + /// the same question if it could not be dismissed. It can: F2 asks that `Esc` + /// close the surface rather than the turn, and a dismissal that was inferred + /// from the queue could only be honoured by dropping the queue. This is the + /// one bit that says *the operator has seen it*, and it is set true again the + /// moment something new is queued, because a new line is a new thing to have + /// seen. + /// + /// [`App::queue_open`] is the predicate every caller asks, and it is the + /// three facts together: opened, still queued, and a turn to be queued behind. + queue_open: bool, /// Every image attached in this session, by path, oldest first. /// /// The index into it is the number in `[Image #1]`, and the path is all that @@ -277,6 +292,7 @@ impl App { contained: false, queued: Vec::new(), prompts: Vec::new(), + queue_open: false, images: Vec::new(), turn_rows: 0, echo_rows: 0, @@ -636,6 +652,26 @@ impl App { /// itself — state, which a surface can draw and a notice cannot. pub fn queue_prompt(&mut self, text: impl Into) { self.prompts.push(text.into()); + // **The surface opens itself, and that is a decision rather than a + // convenience.** Every other surface in this product is opened by a key: + // the fleet view has one, the pickers have commands, and each of them is + // something an operator went looking for. This one is not — the operator + // went looking for a *turn*, and what they got instead was a line held + // back. The queue is the whole of the explanation for that, so it appears + // at the moment there is something to explain and leaves when there is + // not. The key it does not have is the one it would have had to buy: an + // `Action` of its own is an entry in `keys::Action::ALL` — which is + // index-sensitive and asserted as such — plus a name, a default binding, + // a rebindable flag, a row in `commands::KEYS` and two in the README, all + // for a surface whose whole content is already known to the session. + self.queue_open = true; + // The status line's depth is assigned at each of the three sites that + // move the queue rather than synced from a tick: `App::tick` returns + // early unless a turn is running, and the queue drains precisely when one + // ends, so a sync there would leave a stale depth on the line at the idle + // prompt — which is the one moment the number is a lie an operator can + // act on. + self.status.queued_prompts = self.prompts.len(); let waiting = self.prompts.len(); let dash = self.theme.glyphs.dash; // Muted, like the picture held one line over: nothing has gone wrong and @@ -664,7 +700,9 @@ impl App { if self.prompts.is_empty() { return None; } - Some(self.prompts.remove(0)) + let next = self.prompts.remove(0); + self.status.queued_prompts = self.prompts.len(); + Some(next) } /// What is waiting, oldest first. @@ -676,6 +714,23 @@ impl App { &self.prompts } + /// Whether the queue surface is on screen. + /// + /// **Three facts, and none of them is a fourth field.** It has been opened + /// and not dismissed; there is something waiting to draw; and a turn is + /// running to be waiting behind. The last is what closes it when the turn + /// ends, for the same reason [`App::finished`] closes the fleet view — a + /// surface left standing over an idle session describes a state that is no + /// longer true — and it is `mode` that says so rather than a line in + /// `finished`, which is why it comes *back* for the second queued turn. A + /// flag cleared there would have shut the queue for the whole of the drain, + /// which is the run in which the two lines still waiting most want a row. + /// + /// The middle fact is why nothing has to close it when the queue empties. + pub fn queue_open(&self) -> bool { + self.queue_open && self.mode == Mode::Running && !self.prompts.is_empty() + } + /// Drop everything still waiting, and report how much was dropped. /// /// An operator stopping a turn is stopping the session, not just the step in @@ -683,6 +738,7 @@ impl App { /// three more turns, which is a key that reads as broken — and the prompts /// were typed against a conversation that was going somewhere else. pub fn forget_queued_prompts(&mut self) -> usize { + self.status.queued_prompts = 0; std::mem::take(&mut self.prompts).len() } @@ -1262,6 +1318,27 @@ impl App { _ => {} } } + // The queue surface owns exactly one key, and only while it is up: `Esc` + // shuts it. Everything else falls through to the match below — `Ctrl+C` + // still interrupts, the arrows still edit, and the composer still takes + // typing, which it has to, because typing is how the *next* line joins + // the queue this is drawing. + // + // It costs the turn one extra `Esc`, and that is the right way round. + // While a turn runs `Esc` stops it, and an operator who has just been + // shown a list has a reading of that key which is not "stop the run" — + // so the first press answers the surface and the second reaches the turn. + // The same trade the fleet view makes, below it for the same reason: the + // view that was opened by a key is the one that should close first. + // + // Guarded on the *taken* arming rather than on `self.armed`, which this + // function emptied a few lines above: `Esc` can be the second key of a + // rebound chord, and a surface that stole it would be answering a + // sequence the operator was half way through. + if self.queue_open() && key.code == KeyCode::Esc && armed.is_none() { + self.queue_open = false; + return Command::None; + } match hit { Some(Hit::Fire(Action::Interrupt)) => self.interrupt_or_quit(), // The one key in this product that changes the operator's files on @@ -1508,7 +1585,20 @@ impl App { // first given up, because it carries nothing — but what it buys is the // sticky row reading as a header over the work rather than as the last // line of it. - let air_rows = u16::from(area.height >= 8); + // + // **The queue takes it while it is open, and this is the whole reason + // the surface is visible at all (0.17.0).** At the viewport a running + // turn actually asks for — `term::VIEWPORT_HEIGHT`, eight rows — the + // composer's allowance works out to exactly `COMPOSER_ROWS`, so there is + // no spare row above it and a surface drawn there would draw nothing on + // every real session. The alternatives were to grow the viewport, which + // costs every session a row of scrollback for a surface that is empty + // almost always, or to take the composer's own row, which is the one + // thing this layout has refused since 0.1.0. The blank is the honest + // third answer: it carries nothing by its own argument above, the queue + // carries something, and the frame is the same height either way — which + // is what N2 is really about. It comes back the moment the queue closes. + let air_rows = u16::from(area.height >= 8 && !self.queue_open()); // **A rule over the composer, matching the one under it.** The footer has // opened with one since 0.1.0, and the prompt had a boundary on one side // only — so the composer read as part of whatever the turn had last @@ -1586,7 +1676,51 @@ impl App { if self.fleet_open { self.fleet.render(frame, composer, &self.theme); } else { - self.composer.render(frame, composer, &self.theme); + // **The queue takes what the composer can spare, and the subtraction + // above is untouched.** Every term in `composer_rows` is a row the + // frame already had; a term for the queue would be a frame that grew + // with the queue, which is a session whose own scrollback is walked + // upward one row per line typed into it. So the rows come out of the + // composer's allowance the way the fleet view's do — the difference + // being that the fleet takes all of them and this takes only what is + // left over `COMPOSER_ROWS`, because a prompt nobody can see is a + // worse trade than a queue nobody can see. + // + // At the viewport height a running turn holds, that leaves nothing: + // `composer_rows` is exactly `COMPOSER_ROWS` there, so this draws + // nothing until the composer's allowance is bigger than its floor. + // That is F2's "on a terminal tall enough to hold them" in one line + // of arithmetic rather than a height compared against a number. + let spare = composer.height.saturating_sub(COMPOSER_ROWS); + let want = u16::try_from(self.prompts.len()).unwrap_or(u16::MAX); + let queue_rows = if self.queue_open() { + spare.min(want) + } else { + 0 + }; + if queue_rows > 0 { + crate::queue::render( + &self.prompts, + frame, + Rect { + height: queue_rows, + ..composer + }, + &self.theme, + ); + } + // Under the queue and never over it: the rows go in send order, and + // the row the operator is typing into is the one that has not been + // sent at all, so it belongs at the bottom of that order. + self.composer.render( + frame, + Rect { + y: composer.y + queue_rows, + height: composer.height - queue_rows, + ..composer + }, + &self.theme, + ); } if status_rows > 0 { diff --git a/src/bridge.rs b/src/bridge.rs index d2d8f10..1800333 100644 --- a/src/bridge.rs +++ b/src/bridge.rs @@ -17,18 +17,26 @@ use std::sync::Arc; use io_harness::{Flow, Observer, RunEvent}; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; -/// The observer handed to `Session::turn_steered` and to -/// `Session::turn_contained_bounded_observed`. +/// The observer handed to `Session::turn_bounded_steered` and to +/// `Session::turn_contained_bounded_steered`. pub struct Bridge { events: UnboundedSender, - /// Set to end a *contained* turn, and never set for a steered one. + /// Set to end a turn, contained or not. **The whole of how `Ctrl+C` stops a + /// run**, and the only mechanism this product will use for it. /// - /// A contained turn takes no `SteerInbox` — the entry point that reaches the - /// spawn loop has no parameter for one — so the only way an interface can - /// stop it is the return value of this method. io-harness honours - /// [`Flow::Cancel`] at the next step boundary at which no child is in flight, - /// which is a real wait and is disclosed as one rather than presented as an - /// immediate stop. + /// Since 0.17.0 both turn shapes also carry a `SteerInbox`, so + /// `Steer::interrupt` is a second way to reach the same + /// `RunOutcome::Cancelled` at the same step boundary — and the stop key is + /// deliberately not moved onto it. The two are recorded by different code in + /// io-harness, an operator cannot tell them apart from the screen, and this + /// is the one key no configuration file may rebind: a mechanism swap here + /// costs a rewrite of `tests/interrupt.rs` and buys nothing anybody can see. + /// The inbox carries the operator's *words*; this flag carries their stop. + /// + /// io-harness honours [`Flow::Cancel`] at the next step boundary — on a + /// contained turn, the next one at which no child is in flight, which is a + /// real wait and is disclosed as one rather than presented as an immediate + /// stop. /// /// Shared rather than checked through a channel because `event` must not /// block: it runs on the run's own task, in order, for every event. @@ -48,11 +56,11 @@ pub fn channel() -> (Bridge, UnboundedReceiver) { } impl Bridge { - /// The switch that ends a contained turn, for the driver to hold. + /// The switch that ends a turn, for the driver to hold. /// - /// Handed out only where a contained turn is started. A steered turn leaves - /// it untouched for its whole life, which is what keeps `Ctrl+C` on the path - /// it has had since 0.1.0 for every session that configures no caps. + /// Taken once per turn, contained or not, and set by the stop key and by + /// nothing else — which is what keeps `Ctrl+C` on the path it has had since + /// 0.1.0 now that a turn can also be spoken to. pub fn canceller(&self) -> Arc { Arc::clone(&self.cancel) } @@ -63,12 +71,11 @@ impl Observer for Bridge { // A send error means the interface is gone — the process is exiting, or // the receiver was dropped. Nothing to do about it here. let _ = self.events.send(event.clone()); - // A steered turn is stopped through `Steer::interrupt`, which ends it at - // a step boundary and leaves it resumable, and nothing sets this flag on - // that path. A contained turn has no inbox to interrupt, so this is the - // one place it can be stopped from — and it is read, never assumed: a - // bridge that answered `Cancel` unconditionally would cancel every - // steered turn the moment its first event arrived. + // Read, never assumed: a bridge that answered `Cancel` because a turn was + // running would cancel every turn the moment its first event arrived. + // This is the one place either kind of turn is stopped from — the steer + // inbox both of them now carry is for the operator's words, not their + // stop key. if self.cancel.load(Ordering::Relaxed) { return Flow::Cancel; } diff --git a/src/commands.rs b/src/commands.rs index 36a9981..ee79be7 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -144,6 +144,10 @@ pub const COMMANDS: &[(&str, &str)] = &[ "/context", "what is in the model's window, read from the request that carried the turn", ), + ( + "/steer", + "send what is queued into the turn that is already running", + ), ("/copy", "put the last answer on the system clipboard"), ( "/copy diff", @@ -242,7 +246,10 @@ pub const GROUPS: &[(Group, &[&str])] = &[ Group::Session, &["/clear", "/resume", "/fork", "/setup", "/exit"], ), - (Group::Turn, &["/model", "/contain", "/plan", "/profile"]), + ( + Group::Turn, + &["/model", "/contain", "/plan", "/profile", "/steer"], + ), ( Group::Inspect, &[ @@ -670,6 +677,16 @@ pub enum Action { /// budgets in force, the context fill, the servers that came up — so what is /// committed is the state io-harness is in and not io-cli's account of it. Status, + /// Send what is queued into the turn that is already running. + /// + /// io-harness delivers it at the next step boundary, so the step in flight + /// finishes whole and the agent reads the correction before it chooses what + /// to do next. Deliberately a word rather than a default: a delivered steer + /// emits no event this interface can render, so a line sent automatically + /// would leave the screen with no echo at all — and `Steer::say` has no + /// undo, which would make every note typed to oneself mid-turn an + /// instruction to the agent. + Steer, /// Commit what the model's window actually held, section by section. /// /// Read off the request that carried the last turn and never reconstructed: @@ -874,6 +891,12 @@ pub fn parse(input: &str, keys: &Keys, theme: &Theme) -> Action { _ => Action::Plan(None), }, "fleet" | "agents" => Action::Fleet, + // Answered by the driver while a turn is running, which is the only time + // there is a turn to steer. It reaches this arm only at an idle prompt, + // where the honest answer is what it would have done and why it cannot — + // not "there is no such command", which is what an unregistered name + // gets and would be a lie about a command the palette lists. + "steer" => Action::Steer, // **`/attach` is gone, and 0.13.1 is where it went.** A picture is // attached by dropping it on the prompt or pasting it — which is what an // operator already does in every other window they talk to a model in — diff --git a/src/context.rs b/src/context.rs index f95945a..e365404 100644 --- a/src/context.rs +++ b/src/context.rs @@ -222,7 +222,10 @@ fn server_of(tool: &str, contract: &TaskContract) -> String { None => return String::new(), }; for server in &contract.mcp { - if bare.strip_prefix(&server.id).is_some_and(|r| r.starts_with("__")) { + if bare + .strip_prefix(&server.id) + .is_some_and(|r| r.starts_with("__")) + { return server.id.clone(); } } diff --git a/src/lib.rs b/src/lib.rs index 794376a..de32d23 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -53,6 +53,7 @@ pub mod picture; pub mod plan; pub mod provider; pub mod providers; +pub mod queue; pub mod rewind; pub mod servers; pub mod sessions; diff --git a/src/main.rs b/src/main.rs index f281dc4..7885ff6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ use std::time::Instant; use clap::Parser; use crossterm::event::{Event, KeyEventKind}; -use io_harness::{Config, Policy, Provider, ProviderSpec, Session, Store, Templates}; +use io_harness::{Config, Policy, Provider, ProviderSpec, Session, Steer, Store, Templates}; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver}; use io_cli::app::{App, Command}; @@ -1438,6 +1438,17 @@ async fn loop_over Result>( ); screen.commit(&lines).map_err(|error| error.to_string())?; } + // Reached only at an idle prompt: while a turn runs the driver's + // own key handler answers `/steer` before `parse` is ever called, + // because that is where the inbox lives. So this arm is the + // honest "nothing to steer" and it says what the command would + // have done, rather than leaving a registered command looking + // broken to the operator who just found it in the palette. + Action::Steer => app.say( + Tone::Muted, + "nothing is running — /steer sends what is queued into a turn that is already \ + working, and it is read at that turn's next step", + ), Action::Context => { // `reading` for the same reason the arm above binds it that // way — `tests/plan.rs` reads the plan-gate argument off a @@ -1799,20 +1810,45 @@ async fn turn( app.started(); paint(screen, app)?; + // **The fifth seam, and the only one that speaks to a turn already in + // flight.** Every other channel above is something the run asks *this* + // interface for — an approval, a plan, an answer. This one goes the other + // way: `Steer::say` puts the operator's words into the run's own ledger at + // the next step boundary, so the step after it is composed with them present. + // + // Three things about it that decide the code below: + // + // - `SteerInbox` is not `Clone` and holds a `RefCell`, so it is `!Sync` and a + // future borrowing it is not `Send`. That costs nothing here — the boxed + // future below has never had a `Send` bound and is driven on this task — + // but it is why the inbox stays a local and only the `Steer` half could + // ever be handed anywhere else. + // - A delivered steer emits **no observer event**. There is no + // `EventKind::Steered`; io-harness records a `steered` row in the run's + // context trace instead. So nothing that arrives through `bridge` can + // confirm delivery, and nothing this interface says may claim it — see the + // `/steer` arm in the loop, which says *sent*, and the drain after the loop, + // which is the one thing here that can honestly say *not* delivered. + // - Only the root is steered. `extras_for` in io-harness returns no extras + // below depth zero, so on the contained arm a spawned child never hears the + // operator; the agent that reads the message is the one that spawned it. + let (steer, inbox) = Steer::channel(); + // **The two turns this product can take, and one loop over both.** They are // genuinely different turns rather than one turn with a flag: only the // contained entry point passes a containment into io-harness's driver, so - // only it reaches the loop that owns the spawn tool — and it takes no - // `SteerInbox`, because no session entry point takes a caller's containment - // and a steer inbox together. Boxed to one type so the `select!` below is - // written once; a second loop would be a second place `Ctrl+C`, the ticker - // and the event drain could drift. + // only it reaches the loop that owns the spawn tool. Boxed to one type so the + // `select!` below is written once; a second loop would be a second place + // `Ctrl+C`, the ticker and the event drain could drift. // - // 0.10.0 — the contained arm is `turn_contained_bounded_observed` and carries - // a contract io-cli built, which is what a responder, a plan gate, MCP, LSP, - // a browser and skills are fields of. The flat arm is untouched: it is still - // `turn_steered`, still `default_contract`, and still the only one that can - // be steered mid-turn. + // 0.17.0 — both arms take a contract **and** an inbox, which through 0.66 was + // a choice. `turn_bounded_observed` and `turn_contained_bounded_observed` had + // no parameter for a steer inbox, so a session that wanted its own contract + // gave up steering to get one; io-cli made that trade in 0.11.0 and paid for + // it with a turn nobody could correct. io-harness 0.67.0 opened both, and + // `turn_bounded_steered` / `turn_contained_bounded_steered` are positionally + // the same two calls with `&inbox` appended — which is why the change that + // gives an operator their voice back mid-turn is one argument on each arm. // Taken before the future borrows the session, because it is needed inside the // loop and `running` holds `&mut session` for the whole of it. let root = session.root().to_path_buf(); @@ -1825,12 +1861,8 @@ async fn turn( // fixed, and a turn that read a repository and wrote a file ended on // `error: step_cap_reached` with the work half done. // - // `turn_bounded_observed` takes a contract, streams the model's text, and is - // not contained. What it does not take is a steer inbox, and that costs - // nothing this interface offered: the only thing io-cli ever sent through - // one was an interrupt, and the observer's `Flow::Cancel` — the path a - // contained turn has always been stopped by — ends a turn at the same step - // boundary. + // `turn_bounded_steered` takes a contract, streams the model's text, is not + // contained, and reads the inbox above at every step boundary. // **Neither of the two seams rides containment any more.** The responder is // unconditional: io-harness resolves it inside the tool dispatch on any run, // so a question asked on an ordinary turn reaches the person watching instead @@ -1862,12 +1894,12 @@ async fn turn( let mut running: std::pin::Pin< Box> + '_>, > = match containment { - Some(caps) => Box::pin(session.turn_contained_bounded_observed( - &contract, provider, store, policy, &approver, caps, &observer, + Some(caps) => Box::pin(session.turn_contained_bounded_steered( + &contract, provider, store, policy, &approver, caps, &observer, &inbox, + )), + None => Box::pin(session.turn_bounded_steered( + &contract, provider, store, policy, &approver, &observer, &inbox, )), - None => Box::pin( - session.turn_bounded_observed(&contract, provider, store, policy, &approver, &observer), - ), }; // Lives for the turn and no longer, which is half of why an idle session @@ -1943,13 +1975,18 @@ async fn turn( let command = app.key(key); match command { Command::Interrupt => { - // **One path for both kinds of turn.** Neither - // takes a steer inbox any more — a contained turn - // never did, and the flat one gave its up for a - // contract — so the observer is what io-harness - // reads from the interface while a run goes, and - // it honours `Flow::Cancel` at the next step - // boundary. That is the sentence + // **One path for both kinds of turn, and it is + // the observer's.** Both arms now hold a + // `SteerInbox` and `Steer::interrupt` would also + // end the turn at a step boundary — and this key + // is deliberately not routed through it. The two + // paths end in the same `RunOutcome::Cancelled` + // but they are recorded by different code in + // io-harness, and moving the one key this product + // refuses to let a configuration file rebind onto + // a different mechanism buys an operator nothing + // they can see. `Flow::Cancel` is honoured at the + // next step boundary, which is the sentence // `App::interrupt_or_quit` has just put on screen. canceller.store(true, std::sync::atomic::Ordering::Relaxed); stopped = true; @@ -2001,6 +2038,100 @@ async fn turn( // interface reporting a run it did not observe. break None; } + // **`/steer` — what is queued goes to the turn that is + // still running, and only because the operator asked.** + // + // The open question this release had to answer was + // whether a line typed mid-turn should reach the + // agent by itself. It must not, and the reason is the + // second note where the inbox is built: a delivered + // steer emits no event, so an interface cannot show + // that the agent heard it. A + // line sent by default would leave the screen with no + // echo, no cell and no confirmation — the same shape + // as the keystroke 0.16.0 lost, which `App::compose` + // has just been fixed to stop losing. A queue is + // visible state a surface can draw; a steer is not. + // And `Steer::say` has no undo: an operator writing a + // note to themselves while an agent works would be + // changing what it does, once per stray sentence. + // + // So the queue keeps its promise — three lines are + // three turns — and this is the one word that spends + // it differently. It is a slash command rather than a + // key because `App::compose` already lets `/` through + // mid-turn on purpose, so nothing in the library had + // to learn a new mode for it. + // + // **Said, never claimed delivered.** io-harness takes + // the message at the next step boundary and records a + // `steered` row in the run's own trace; no + // `RunEvent` carries it, so `Ok` here means the + // channel accepted the words and nothing more. The + // sentence says exactly that. What *is* certain is + // the negative, and the drain after this loop is + // where it gets said. + Command::Slash(ref line) if line.trim() == "steer" => { + let dash = app.theme.glyphs.dash; + let mut sent = 0usize; + // The summary below is skipped when this is set, + // because a count is not the answer to a refusal + // — and `App::say` keeps one notice, so a + // summary written over the error would be the + // interface reporting success on the one path + // where there was none. + let mut refused = false; + // Each on its own, in the order they were typed: + // io-harness pushes one `Observation` per message + // and the model reads them in that order. Joining + // them would be one paragraph the operator never + // wrote. + while let Some(waiting) = app.next_queued_prompt() { + if let Err(error) = steer.say(waiting.clone()) { + // Unreachable while `inbox` is alive — + // and said rather than swallowed anyway, + // because the one thing worse than a + // correction that arrives late is one + // that reports success and goes nowhere. + app.queue_prompt(waiting); + app.say( + Tone::Warning, + format!("nothing is listening {dash} {error}"), + ); + refused = true; + break; + } + // Into the transcript, not the footer. A + // steered line becomes an observation in the + // run's ledger, so it is part of the + // conversation rather than about it — and it + // is the only part that will never get an + // echo of its own, because it is not a turn. + app.record( + Tone::Muted, + format!("[mid-turn] {}", waiting.trim()), + ); + sent += 1; + } + if !refused { + app.say( + Tone::Muted, + match sent { + 0 => format!( + "nothing queued {dash} type a line first, then \ + /steer" + ), + 1 => format!( + "sent {dash} the turn reads it at its next step" + ), + many => format!( + "{many} sent {dash} the turn reads them at its \ + next step" + ), + }, + ); + } + } // Refused with a sentence rather than dropped in // silence. `/resume`, `/fork` and the rewind each move // the session head this turn is about to write, and @@ -2100,6 +2231,28 @@ async fn turn( } app.finished(); + // **The one delivery fact this interface can state, and it is the negative + // one.** io-harness emits no event for a message it delivered, so there is + // nothing to read on the way in; what is left in the inbox after the turn has + // returned, though, is exactly what no step got to. `SteerInbox::pending` is + // public for this — a caller draining an inbox it is no longer handing to a + // turn, rather than discovering later that the operator's last words went + // with the channel. + // + // Into the transcript, because the words are the operator's and the sentence + // is about a run that has already ended — a footer notice would be gone at + // the next keystroke, and it would fight with `stopped` below. + // + // Not re-queued. A turn that ended because the operator stopped it must not + // start another one, and a turn that ended on its own has a composer waiting + // with nothing in the way. + for lost in inbox.pending().messages { + app.record( + Tone::Muted, + format!("[mid-turn, not delivered] {}", lost.trim()), + ); + } + match outcome { // The operator's sentence in front of the harness's own line — see // `io_cli::failure`. A provider that will not take an image says so in the diff --git a/src/provider.rs b/src/provider.rs index e2c8186..0c899db 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -187,10 +187,13 @@ impl Provider for Watched

{ on_call: &(dyn Fn(usize, &ToolCall) + Send + Sync), ) -> impl std::future::Future> { self.seen.record(&request); - self.inner.complete_streaming_calls(request, on_token, on_call) + self.inner + .complete_streaming_calls(request, on_token, on_call) } - fn models(&self) -> impl std::future::Future>> + Send { + fn models( + &self, + ) -> impl std::future::Future>> + Send { self.inner.models() } diff --git a/src/queue.rs b/src/queue.rs new file mode 100644 index 0000000..50593ba --- /dev/null +++ b/src/queue.rs @@ -0,0 +1,155 @@ +//! The queue surface: what is waiting behind the turn that is running. +//! +//! **A renderer over [`crate::app::App`]'s waiting prompts, and nothing else.** +//! It owns no state, decides nothing about when a queued line runs, and cannot +//! reorder one. `App` holds the queue because the driver drains it; this draws +//! it, and the separation is what keeps "what is waiting" a single fact with a +//! single owner. +//! +//! Three decisions shape everything here, and each of them is a *refusal*. +//! +//! **It is not modal.** An approval, a question and a plan take the whole +//! viewport because a run is stopped inside a harness callback waiting for the +//! answer — see [`crate::app::App::modal`], which is the one predicate naming +//! all three. Nothing is blocked by a queue: the turn goes on streaming, the +//! operator goes on typing, and the only thing this surface knows is a list. So +//! it takes no keyboard, sets no cursor, and the session it is drawn over +//! behaves exactly as it did before it appeared. Adding it to `modal()` would +//! make `Ctrl+C` reach a guard rather than the turn, which is a surface that +//! swallows the interrupt while claiming to be a list. +//! +//! **It never grows the viewport.** The rows it draws come out of the +//! composer's own allowance, the way [`crate::fleet::Fleet::render`] takes them, +//! and the composer keeps [`crate::app::COMPOSER_ROWS`] whatever is queued. +//! A surface that claimed a row per queued line would walk the transcript +//! upward by its own queue — every line typed mid-turn pushing the conversation +//! it was typed against off the screen — and the queue is at its longest exactly +//! when the turn under it is at its most worth reading. +//! +//! **It never hides the prompt.** This is the one place it parts company with +//! the fleet view, which takes the whole composer rect. That view is opened by a +//! key, so an operator who cannot see the prompt knows why and closes it; this +//! one opens on its own the moment something is queued, and a surface that +//! appears unasked and takes the prompt with it is a session that looks hung to +//! the person who caused it. So it draws above the composer or it does not draw +//! at all. +//! +//! What falls out of the last two together is the honest shape of a short +//! viewport: when the composer's allowance is a single row there is nothing to +//! spare, and this draws nothing. The notice +//! [`crate::app::App::queue_prompt`] already wrote is what says the line was +//! kept — a surface is what keeps it visible *afterwards*, and afterwards is +//! worth a row only when there is one to give. + +use ratatui::layout::Rect; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use crate::glyphs::Glyphs; +use crate::picker::fit; +use crate::theme::{Theme, Tone}; + +/// The rows this surface would draw for `waiting`, oldest first, at most `room` +/// of them and none wider than `width`. +/// +/// **The oldest are the ones kept.** They are the ones that run next, so a +/// window that followed the newest would show the operator the part of the queue +/// they are furthest from seeing happen. What did not fit is a count on the last +/// row rather than silence: a list that stops at the bottom of its rows and says +/// nothing is a queue an operator reads as three long when it is nine long, and +/// [`Glyphs::elision`] exists for exactly this — *lines are missing here*, +/// always followed by how many. +/// +/// Separate from [`render`] so the arithmetic can be asserted without a frame. +pub fn rows(waiting: &[String], width: u16, room: u16, glyphs: &Glyphs) -> Vec { + let room = usize::from(room); + if room == 0 || waiting.is_empty() { + return Vec::new(); + } + // **One row is the case that matters, because one row is what the running + // viewport actually has.** Eight rows, of which the streaming tail, the + // activity line, the rule and the three-row footer take six and the composer + // keeps its floor — the blank this surface borrows is the seventh, and it + // leaves exactly one. Spending that row on the count alone, which is what + // reserving a row for the elision would do here, would draw a surface that + // says three lines are waiting and never says what any of them is. The row + // goes to the line that runs NEXT, with the rest counted on the end of it. + let width = usize::from(width); + if room == 1 && waiting.len() > 1 { + let rest = waiting.len() - 1; + return vec![fit( + &format!( + "1. {} {} {rest} more", + one_line(&waiting[0]), + glyphs.elision + ), + width, + glyphs, + )]; + } + // The whole queue when it fits; otherwise one row fewer, because the last + // row is spent saying what is under it. + let shown = if waiting.len() <= room { + waiting.len() + } else { + room - 1 + }; + let mut out: Vec = waiting + .iter() + .take(shown) + .enumerate() + // Numbered rather than headed. A header row would spend the surface's + // only row on a terminal that can spare one, and would repeat a sentence + // the footer notice has already said; the position says both what the + // rows are and which order they run in, on every row, for no rows at + // all. + .map(|(at, prompt)| fit(&format!("{}. {}", at + 1, one_line(prompt)), width, glyphs)) + .collect(); + let hidden = waiting.len() - shown; + if hidden > 0 { + out.push(fit( + &format!("{} {hidden} more", glyphs.elision), + width, + glyphs, + )); + } + out +} + +/// A prompt as one row's worth of text. +/// +/// **The newline is the danger, not the length.** A prompt finished with +/// `Shift+Enter` in it is several lines, and a `Line` handed text with a `\n` +/// inside it draws rows the layout never budgeted for — which is the one way a +/// surface that takes a fixed number of rows can push the composer off the +/// screen anyway. Collapsing the whitespace also takes tabs and carriage +/// returns, which a paste can carry and neither of which is a row of anything. +fn one_line(prompt: &str) -> String { + prompt.split_whitespace().collect::>().join(" ") +} + +/// Draw what is waiting into the rows it has been given, and no others. +/// +/// Muted, because none of it has run: the composer under it and the turn above +/// it are what is live, and a queue drawn in the colour of either would compete +/// with them for an attention it has no claim on. +/// +/// **No cursor.** [`crate::fleet::Fleet::render`] parks one on its marked row +/// because the arrows move that marker and a reader has to be able to find it; +/// nothing here is selectable, and the composer below owns the cursor. A second +/// widget setting it would move the terminal's own cursor off the prompt an +/// operator is typing into. +pub fn render(waiting: &[String], frame: &mut Frame, area: Rect, theme: &Theme) { + let lines: Vec> = rows(waiting, area.width, area.height, &theme.glyphs) + .into_iter() + .map(|row| Line::from(Span::styled(row, theme.style(Tone::Muted)))) + .collect(); + if lines.is_empty() { + return; + } + // Unwrapped on purpose: `rows` has already cut every line to the width, and + // a wrap would turn one queued prompt into two rows inside a rectangle + // measured in prompts. + frame.render_widget(Paragraph::new(lines), area); +} diff --git a/src/status.rs b/src/status.rs index 6220a81..bcc950b 100644 --- a/src/status.rs +++ b/src/status.rs @@ -364,6 +364,27 @@ pub struct Status { /// every session carries until a contract says otherwise, and it draws /// nothing. pub budgets: Budgets, + /// Prompts the operator finished while a turn had the session, still waiting + /// to run. + /// + /// **A session fact and not a run fact, so [`Status::forget_run`] leaves it + /// standing — and the reason is sharper here than it is for `planning` or + /// `budgets`.** The queue itself is `App::prompts`; `forget_run` takes + /// `&mut Status` and can reach nothing else. Clearing the count here would + /// therefore not empty the queue, only stop saying how deep it is — and the + /// prompts would still fire, a turn each, out of a session whose line had + /// just finished claiming there were none. That is worse than the hole it + /// would be closing. `/resume`, `/fork` and a rewind change which + /// conversation is on screen and drop nothing the operator typed; + /// `App::forget_queued_prompts` is the one thing that drops them, and it + /// moves this number by moving the queue rather than by contradicting it. + /// + /// Zero renders as **nothing at all**, the rule `bg N`, `spend` and `tokens` + /// are held to: a session nobody typed ahead of has not queued zero prompts. + /// It is what keeps this field off the overwhelming majority of lines — a + /// queue exists only between an `Enter` pressed mid-turn and the end of the + /// turn after it. + pub queued_prompts: usize, /// Which frame of the indicator is showing. Advanced by the tick, never by /// the clock: an indicator that read the time would be a second timer. frame: usize, @@ -393,6 +414,7 @@ impl Status { elapsed: Duration::ZERO, plain: false, budgets: Budgets::default(), + queued_prompts: 0, frame: 0, } } @@ -423,8 +445,11 @@ impl Status { /// read, and absent is the honest answer until the agent writes a list in the /// run that is now on screen. /// - /// The model, the posture, plain mode and the session's age are not run facts - /// and are left alone — the session is the same session either way. + /// The model, the posture, plain mode, the session's age and the depth of the + /// prompt queue are not run facts and are left alone — the session is the + /// same session either way, and the queue in particular is not even reachable + /// from here: see [`Status::queued_prompts`] for why blanking the count + /// would leave the line contradicting prompts that are still going to run. pub fn forget_run(&mut self) { self.tokens = None; // Both new in 0.11.0, and both run facts. The provider is the one that @@ -568,6 +593,25 @@ impl Status { left } + /// What is waiting behind this turn, or nothing at all when nothing is. + /// + /// **One method feeding both renderers, for exactly the reason + /// [`Status::budgets_left`] is one, and N3 is that lesson a third time.** + /// [`Status::render`] takes [`Status::footer`] on any terminal seven rows or + /// taller — which is every real terminal — so [`Status::line`] is the + /// short-terminal fallback and the footer is what an operator is actually + /// looking at. A depth composed separately in each would be two spellings a + /// test could satisfy one of; a depth composed in `line` alone would be + /// 0.12.0's planning field again, green in a unit test and nowhere on screen + /// in a live capture. + /// + /// `Option` rather than a `String` that is sometimes empty, so the absence at + /// zero is decided here and cannot be forgotten by a caller: both renderers + /// push whatever this returns, and neither one asks about the count itself. + pub fn queued_left(&self) -> Option { + (self.queued_prompts > 0).then(|| format!("queued {}", self.queued_prompts)) + } + /// The fields, most important first. pub fn fields(&self, theme: &Theme) -> Vec { // The WORD is the state, and the animation is only beside it. A spinner @@ -608,6 +652,17 @@ impl Status { if let Some(provider) = &self.provider { fields.push(Field::new(format!("provider:{provider}"), Tone::Muted)); } + // **Left of the background-job count, and that is a decision about what + // survives a narrow terminal rather than a tidy grouping.** `bg N` is + // work the agent started and the transcript a row above already names it; + // this is a keystroke io-cli took and has not run yet, and the only other + // thing that ever said so was a footer notice the next keystroke erases. + // Dropped, a prompt the operator watched vanish out of the composer is + // evidenced nowhere on screen at all — which is the lost keystroke this + // release exists to end, arrived at from the other side. + if let Some(text) = self.queued_left() { + fields.push(Field::new(text, Tone::Normal)); + } // **Immediately right of the clock, and above everything else this line // carries.** 0.8.0 drafted the spend field to the right of the containment // word and it was invisible on the first terminal it met; the lesson is @@ -848,6 +903,14 @@ impl Status { if let Some((done, total)) = self.plan { counts.push(format!("plan {done}/{total}")); } + // **Here as well as on `Status::line`, out of the same method, for the + // reason the budgets four lines up are here.** This is the row the binary + // draws at an ordinary prompt, so a depth added to `line` alone would be + // a depth no operator ever saw — 0.12.0's planning field, again. Left of + // `bg` here too, so the two forms read the same order as well as the same + // words. `extend` over the `Option` because absent contributes nothing, + // which is the whole of the zero case. + counts.extend(self.queued_left()); if self.jobs > 0 { counts.push(format!("bg {}", self.jobs)); } @@ -1351,6 +1414,20 @@ pub fn committed( }, )); + // **`Status::queued_prompts` is deliberately not a row here, and the absence + // is a decision rather than a renderer somebody missed.** Every fact above + // is either a standing configuration — the workspace, the layers, the caps, + // the budgets, the rosters — or a session total that only ever climbs, so + // each one is still a true account of this session at the moment it was + // written. The queue is neither: it exists between an `Enter` pressed + // mid-turn and the end of the turn after it, and this page is *committed*, + // into a scrollback that keeps it for the rest of the session. A row reading + // `queue: 2 waiting` is false a turn later and goes on saying it, which would + // make it the one row on a page whose whole argument is that a status surface + // a reader cannot trust is worse than none. The line and the footer redraw, + // so they are where a depth that changes belongs — which is also why N3 names + // those two renderers and not this one. + // Three of whatever the set draws a rule with, at both ends — the same edge // `crate::transcript` gives a committed conversation, and for the same // reason: this lands in a scrollback that already holds every earlier turn, diff --git a/tests/commands.rs b/tests/commands.rs index 2114521..318e4b4 100644 --- a/tests/commands.rs +++ b/tests/commands.rs @@ -45,6 +45,9 @@ fn the_commands_are_the_commands() { // 0.17.0 — `/status` says how full the window is and this says what // is in it. Beside it in the table for that reason. "/context", + // 0.17.0 — a word rather than a default, because a delivered steer + // emits no event this interface can draw. + "/steer", "/copy", "/copy diff", "/config", diff --git a/tests/context.rs b/tests/context.rs index 2515acb..5717af9 100644 --- a/tests/context.rs +++ b/tests/context.rs @@ -150,7 +150,10 @@ fn section<'a>(sections: &'a [context::Section], label: &str) -> &'a context::Se struct Silent; impl Provider for Silent { - async fn complete(&self, _request: CompletionRequest) -> io_harness::Result { + async fn complete( + &self, + _request: CompletionRequest, + ) -> io_harness::Result { Ok(CompletionResponse { text: Some("done".into()), ..Default::default() @@ -169,13 +172,21 @@ async fn f7_the_window_is_read_from_the_request_that_carried_the_turn() { let watched = Watched::new(Silent, seen.clone()); let answer = watched.complete(request()).await.expect("the mock answers"); - assert_eq!(answer.text.as_deref(), Some("done"), "the decorator delegates"); + assert_eq!( + answer.text.as_deref(), + Some("done"), + "the decorator delegates" + ); let carried = seen.latest().expect("the request went past"); assert_eq!(carried.system, system(), "the system block, byte for byte"); assert_eq!(carried.messages, 2); assert_eq!( - carried.tools.iter().map(|t| t.name.as_str()).collect::>(), + carried + .tools + .iter() + .map(|t| t.name.as_str()) + .collect::>(), vec!["read_file", UNKNOWN_TOOL, MCP_TOOL], "the catalogue as offered, in the order it was offered", ); @@ -324,8 +335,8 @@ fn f7_the_sections_sum_to_the_total_and_the_total_is_stated_against_the_window() // The denominator is the contract's own budget, not `ContextBudget::default`: // an operator who tightened `[context]` must see the ceiling they set. assert_eq!(context::window(&contract, None), 24_000); - let tightened = TaskContract::workspace("g", "/repo") - .with_context_budget(io_harness::ContextBudget { + let tightened = + TaskContract::workspace("g", "/repo").with_context_budget(io_harness::ContextBudget { max_tokens: 8_000, share: 0.25, }); @@ -412,7 +423,13 @@ fn f7_the_system_block_is_tokens_of_its_own_text_and_not_a_byte_count() { // The arithmetic, on the rendered page rather than on the values behind it — // which is where an operator does this subtraction and where a sabotaged row // stops reconciling. - let page = drawn(&context::committed(Some(&seen), &contract, None, &ascii(), 80)); + let page = drawn(&context::committed( + Some(&seen), + &contract, + None, + &ascii(), + 80, + )); let counted: u64 = page .iter() .filter_map(|row| row.split_once(": ")) @@ -441,7 +458,9 @@ fn f7_the_system_block_is_tokens_of_its_own_text_and_not_a_byte_count() { fn f7_the_page_draws_in_ascii_and_says_so_before_a_turn_has_run() { let empty = drawn(&context::committed(None, &contract(), None, &ascii(), 80)); assert!( - empty.iter().any(|row| row.contains("nothing has been sent yet")), + empty + .iter() + .any(|row| row.contains("nothing has been sent yet")), "a window nothing has been sent through has no contents, and zeroes would \ read as an empty one: {empty:#?}", ); diff --git a/tests/contract.rs b/tests/contract.rs index abd13da..1413cc4 100644 --- a/tests/contract.rs +++ b/tests/contract.rs @@ -785,12 +785,17 @@ fn f6_both_arms_are_handed_one_contract() { // that a newline sits in a particular place is an assertion about formatting // — it would go quietly blind the first time one of them grew an argument. let squashed: String = text.chars().filter(|c| !c.is_whitespace()).collect(); + // `_steered` from 0.17.0. io-harness 0.67.0 opened the two entry points that + // take the caller's contract AND a steer inbox on one call — until then a + // turn could carry a contract or be steered and never both, which is why + // 0.11.0 dropped steering. The argument lists are otherwise identical, so + // what this still asserts is what it always did: one contract, both arms. assert!( - squashed.contains("session.turn_contained_bounded_observed(&contract,"), + squashed.contains("session.turn_contained_bounded_steered(&contract,"), "the contained arm is handed the contract", ); assert!( - squashed.contains("session.turn_bounded_observed(&contract,"), + squashed.contains("session.turn_bounded_steered(&contract,"), "and so is the flat one", ); } diff --git a/tests/dependencies.rs b/tests/dependencies.rs index be27a86..ee35e40 100644 --- a/tests/dependencies.rs +++ b/tests/dependencies.rs @@ -169,7 +169,12 @@ fn f7_no_source_file_loops_over_provider_responses() { // returned. If this module ever reads a response — a token, a tool call, a // message — it has stopped decorating and started interpreting, which is the // first half of an agent loop wherever it is written. - for interpreting in [".choices", ".tool_calls", "match response", "let response ="] { + for interpreting in [ + ".choices", + ".tool_calls", + "match response", + "let response =", + ] { assert!( !forwarding.contains(interpreting), "src/provider.rs reads a provider response (`{interpreting}`) — a decorator \ diff --git a/tests/queue_surface.rs b/tests/queue_surface.rs new file mode 100644 index 0000000..d613a10 --- /dev/null +++ b/tests/queue_surface.rs @@ -0,0 +1,545 @@ +//! F2 — the queue is on screen while the turn runs, and it is not a modal. +//! N2 — the viewport does not grow, whatever is queued. +//! +//! `tests/queue.rs` owns the state: what is captured, in what order, and how the +//! driver drains it. This file owns the *surface*, and the two criteria it +//! carries are both about what a surface must refuse to do. +//! +//! F2 is a refusal of the keyboard. Three surfaces in this product are modal — +//! an approval, a question and a plan — and every one of them is modal because a +//! run is stopped inside a harness callback waiting for the answer. A queue +//! stops nothing, so `App::modal()` must not learn about it: the sabotage that +//! adds it there produces a list that swallows `Ctrl+C`, and the operator's own +//! stop key is answered by a surface they never asked to open. +//! +//! N2 is a refusal of a row. The viewport is subtracted from the terminal and +//! the transcript is the terminal's own scrollback, so a surface that claimed a +//! row per queued line would walk the conversation upward one row for every line +//! typed against it. The rows come out of the composer's allowance instead — +//! the way `Fleet::render` takes them — and the frame is the same height with +//! nine prompts waiting as with none. +//! +//! Asserted on the rendered viewport rather than on the rectangles wherever a +//! reader's answer differs from the renderer's arithmetic: a test that recomputed +//! the offsets would agree with `App::render` by construction. + +mod support; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use io_cli::app::{App, Command, Mode}; +use io_cli::glyphs::{ASCII, UNICODE}; +use io_cli::queue; +use io_cli::theme::DARK; +use io_harness::{EventKind, RunEvent}; + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +/// Type a line and press `Enter`, the way an operator sends one. +fn send(app: &mut App, text: &str) -> Command { + for character in text.chars() { + app.key(key(KeyCode::Char(character))); + } + app.key(key(KeyCode::Enter)) +} + +/// A session with a turn genuinely in flight: the mode, the harness's `Started`, +/// and a streamed tail with no newline on it. +/// +/// The tail is not decoration. `App::undoable` is true for a turn that has done +/// nothing yet, and the stop key *abandons* such a turn rather than interrupting +/// it — so a test that asserted `Ctrl+C` still interrupts would otherwise be +/// asserting it against a turn no interrupt was ever reachable for. +fn running(theme: io_cli::theme::Theme) -> App { + let mut app = App::new(theme, "a-model"); + app.started(); + app.event( + &RunEvent::new( + 1, + 1, + EventKind::Started { + goal: "count the tests".into(), + provider: "test".into(), + }, + ), + std::time::Duration::ZERO, + ); + app.event( + &RunEvent::new( + 1, + 1, + EventKind::Token { + text: "STREAMING".into(), + }, + ), + std::time::Duration::ZERO, + ); + app +} + +/// A running session with `queued` prompts typed into it, in order. +fn with_queue(count: usize) -> App { + let mut app = running(DARK); + for at in 0..count { + assert_eq!( + send(&mut app, &format!("queued prompt {at}")), + Command::None, + "a prompt typed mid-turn is kept rather than sent", + ); + } + app +} + +/// The viewport's rows as text, blanks kept: a row's *index* is the assertion. +fn rows_at(app: &App, viewport: u16) -> Vec { + let (mut screen, _recorder) = support::screen_of(80, 40, viewport); + screen + .draw(|frame| app.render(frame, frame.area())) + .expect("frame"); + screen + .viewport_text() + .split('\n') + .map(str::to_string) + .collect() +} + +/// Where the row holding `needle` is, or a panic naming what was on screen. +fn row_of(rows: &[String], needle: &str) -> usize { + rows.iter() + .position(|row| row.contains(needle)) + .unwrap_or_else(|| panic!("no row holds {needle:?}: {rows:#?}")) +} + +/// A viewport tall enough that the composer's allowance has rows to spare. +/// +/// Twelve rows leaves the composer five — the streaming row, the blank, the +/// activity line, the rule and three rows of footer take the rest — of which one +/// is the composer's own floor and four can be lent. It is not +/// `term::VIEWPORT_HEIGHT`, and that is a finding rather than a convenience: see +/// the note on `n2_the_surface_draws_nothing_when_the_composer_has_no_row_to_spare`. +const TALL: u16 = 12; + +// --------------------------------------------------------------------------- +// F2 — on screen, in order, and not in front of the keyboard +// --------------------------------------------------------------------------- + +/// F2 — the queued lines are drawn above the composer, in the order they were +/// sent. +#[test] +fn f2_the_queued_lines_are_drawn_above_the_composer_in_send_order() { + let app = with_queue(3); + assert!(app.queue_open(), "queueing a line opens the surface"); + + let rows = rows_at(&app, TALL); + let first = row_of(&rows, "queued prompt 0"); + let second = row_of(&rows, "queued prompt 1"); + let third = row_of(&rows, "queued prompt 2"); + let composer = row_of(&rows, io_cli::composer::PROMPT.trim_end()); + + assert!( + first < second && second < third, + "the queue is drawn in the order it was typed, which is the order it \ + runs in: {rows:#?}" + ); + assert!( + third < composer, + "the whole queue sits above the prompt: a line still being typed has not \ + been sent, so it belongs under the ones that have. {rows:#?}" + ); + // The status line survives underneath, exactly as it does under the fleet + // view: the surface takes the composer's rows and nobody else's. + assert!( + rows.len() == usize::from(TALL), + "the surface drew outside the frame it was given: {rows:#?}" + ); +} + +/// F2's sabotage arm: adding the queue to `App::modal()`. +/// +/// **This is the test that kills it.** A modal surface takes the whole viewport +/// and takes the keyboard with it, and the one key it is allowed to let through +/// is `Ctrl+C`. Nothing is blocked by a queue — the turn is still streaming and +/// the composer is still taking typing — so the predicate that names the three +/// genuinely blocked surfaces must not learn a fourth. +#[test] +fn f2_the_queue_surface_is_not_modal_and_ctrl_c_still_interrupts() { + let mut app = with_queue(2); + assert!(app.queue_open()); + assert!( + !app.modal(), + "a queue blocks no run, so no caller may treat it as a surface that does", + ); + + assert_eq!( + app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), + Command::Interrupt, + "the stop key reaches the turn with the surface open", + ); + assert!( + !app.modal(), + "and it is still not modal on the way out of the turn", + ); +} + +/// F2 — `Esc` closes the surface rather than the turn, and the turn goes on. +#[test] +fn f2_esc_closes_the_surface_and_leaves_the_turn_running() { + let mut app = with_queue(2); + assert!(app.queue_open()); + + assert_eq!(app.key(key(KeyCode::Esc)), Command::None); + assert!(!app.queue_open(), "the first Esc answers the surface"); + assert_eq!( + app.mode(), + Mode::Running, + "and it does not touch the turn underneath it", + ); + assert_eq!( + app.queued_prompts().len(), + 2, + "closing the view is not dropping the queue — the lines still run", + ); + + let rows = rows_at(&app, TALL); + assert!( + !rows.iter().any(|row| row.contains("queued prompt 0")), + "a closed surface draws nothing: {rows:#?}" + ); + + // And the second press reaches the turn, which is the key doing what it does + // when no surface is up. + assert_eq!(app.key(key(KeyCode::Esc)), Command::Interrupt); +} + +/// F2 — something newly queued reopens a surface that was dismissed. +/// +/// A dismissal is "I have seen this list", not "never show me one again". The +/// line typed after it is a new thing to have seen, and the notice announcing it +/// scrolls away. +#[test] +fn a_line_queued_after_a_dismissal_opens_the_surface_again() { + let mut app = with_queue(1); + app.key(key(KeyCode::Esc)); + assert!(!app.queue_open()); + + send(&mut app, "one more while it is still running"); + assert!(app.queue_open()); +} + +/// F2 — the surface is up only while a turn is, and comes back for the next +/// queued turn. +/// +/// The first half is the fleet view's rationale: a surface left standing over an +/// idle session describes a state that is no longer true. The second half is +/// what a flag cleared in `App::finished` would have cost — the drain is one +/// turn per queued prompt, and the two lines still waiting during the second +/// turn are exactly the ones worth a row. +#[test] +fn the_surface_closes_with_the_turn_and_comes_back_for_the_next_queued_turn() { + let mut app = with_queue(3); + assert!(app.queue_open()); + + app.finished(); + assert_eq!(app.mode(), Mode::Idle); + assert!(!app.queue_open(), "nothing is queued behind an idle prompt"); + assert_eq!(app.queued_prompts().len(), 3, "the queue itself survives"); + + // The driver's drain: take the oldest and run it. + let next = app.next_queued_prompt().expect("a queued prompt"); + assert_eq!(next, "queued prompt 0"); + app.started(); + assert!( + app.queue_open(), + "the two still waiting are on screen again while their turn runs", + ); + + // And the last one out closes it without anything having to say so. + app.finished(); + app.next_queued_prompt(); + app.next_queued_prompt(); + app.started(); + assert!(app.queued_prompts().is_empty()); + assert!(!app.queue_open()); +} + +/// F2 — the rows carry no box-drawing character under the ASCII glyph set. +/// +/// The sweep is over every non-ASCII character rather than over a list of box +/// corners, which is the same standard `tests/glyphs.rs` holds every other +/// surface to: a terminal that cannot draw `│` cannot draw `·` either, and a +/// queue whose rows arrive as replacement characters is a queue nobody can read. +#[test] +fn f2_the_surface_draws_in_the_ascii_glyph_set_with_no_box_drawing_character() { + let mut app = running(DARK.with_glyphs(ASCII)); + for at in 0..9 { + send(&mut app, &format!("queued prompt {at}")); + } + + let rows = rows_at(&app, TALL); + let first = row_of(&rows, "queued prompt 0"); + let composer = row_of(&rows, io_cli::composer::PROMPT.trim_end()); + let drawn = rows[first..composer].join("\n"); + + if let Some(bad) = drawn.chars().find(|character| !character.is_ascii()) { + panic!( + "the queue surface drew {bad:?} (U+{:04X}) under the ASCII set.\n{drawn}", + bad as u32, + ); + } + // Named explicitly as well, because the sweep above would also pass on a + // surface that drew nothing at all — and these are the characters a list + // reaches for first. + for box_drawing in ['│', '─', '┌', '└', '├', '╭', '╰', '┃'] { + assert!( + !drawn.contains(box_drawing), + "the queue is a list of lines, not a drawn box: {drawn}", + ); + } + assert!( + drawn.contains("queued prompt 0"), + "the sweep must have had something to sweep: {drawn}", + ); +} + +// --------------------------------------------------------------------------- +// N2 — the frame does not grow +// --------------------------------------------------------------------------- + +/// N2's sabotage arm: growing the viewport by one row per queued line. +/// +/// **This is the test that kills it.** Every route to the frame's height is +/// asserted, because the sabotage can be written at any of them: the fixed +/// height the driver asks for between turns, the height it asks for while a turn +/// is in flight, and the rows the frame actually draws into. +#[test] +fn n2_a_queue_of_any_depth_leaves_the_frame_the_same_height_as_an_empty_one() { + let empty = with_queue(0); + let quiet = empty.viewport_height(); + let wanted = empty.viewport_wanted(80, 40); + let rows = rows_at(&empty, wanted).len(); + + for depth in [1, 2, 3, 9, 40] { + let app = with_queue(depth); + assert_eq!( + app.queued_prompts().len(), + depth, + "the fixture queued what it meant to", + ); + assert_eq!( + app.viewport_height(), + quiet, + "{depth} queued lines moved the viewport's own height", + ); + assert_eq!( + app.viewport_wanted(80, 40), + wanted, + "{depth} queued lines made the session ask the terminal for more rows \ + — which is the scrollback being walked upward by its own queue", + ); + // Through the height the session itself asks for, not the one this test + // measured off the empty queue: a frame drawn into a viewport the + // sabotage grew is the row an operator actually loses. + assert_eq!( + rows_at(&app, app.viewport_wanted(80, 40)).len(), + rows, + "{depth} queued lines drew outside the frame", + ); + } +} + +/// N2 — the rows come out of the composer's allowance, so a queue deeper than +/// the rows available takes no more of them. +#[test] +fn n2_the_queue_takes_the_composers_spare_rows_and_never_the_composers_own() { + let shallow = rows_at(&with_queue(1), TALL); + let deep = rows_at(&with_queue(40), TALL); + assert_eq!(shallow.len(), deep.len(), "the frame is the frame"); + + let prompt = io_cli::composer::PROMPT.trim_end(); + assert!( + row_of(&shallow, prompt) < shallow.len(), + "the prompt is still on screen with one line queued: {shallow:#?}" + ); + assert!( + row_of(&deep, prompt) < deep.len(), + "the prompt is still on screen with forty: a surface that took the \ + composer's own row would leave a session with nowhere to type in the \ + middle of a turn. {deep:#?}" + ); + // Four rows to lend at this height, so a queue of forty is cut to three and a + // count — never to forty rows that push the prompt off the frame. + assert!( + deep.iter().any(|row| row.contains("more")), + "a queue deeper than the rows says how much did not fit: {deep:#?}" + ); +} + +/// N2 — the surface is visible at the viewport a running turn actually holds, +/// and it costs that frame no height. +/// +/// **This is the finding the release turned on, and it is asserted rather than +/// left as prose.** `term::VIEWPORT_HEIGHT` is eight rows, of which the +/// streaming tail, the activity line, the rule and the three-row footer take six +/// and the composer keeps its floor of one. That leaves the blank — the row the +/// layout itself describes as carrying nothing — and while the queue is open the +/// queue has it. So the surface draws at the height every real session runs at, +/// the frame is the same height as it was with nothing queued, and the blank +/// comes back the moment the queue empties. +/// +/// The single row goes to the line that runs next with the rest counted on the +/// end, never to the count alone: a surface that says three are waiting and +/// names none of them has spent the only row it will ever get on the half of the +/// answer the status line already carries. +#[test] +fn n2_the_surface_is_visible_at_the_running_viewport_and_costs_it_no_height() { + let app = with_queue(3); + assert!(app.queue_open(), "queueing a line opens the surface"); + + let height = io_cli::term::VIEWPORT_HEIGHT; + let rows = rows_at(&app, height); + let queued = row_of(&rows, "queued prompt 0"); + let prompt = row_of(&rows, io_cli::composer::PROMPT.trim_end()); + assert!( + queued < prompt, + "the next line to run is drawn, above the composer: {rows:#?}", + ); + assert!( + rows[queued].contains("2 more"), + "and the two behind it are counted on the same row: {:?}", + rows[queued], + ); + + // The blank is what it took, so the frame is the height it always was. + let quiet = rows_at(&with_queue(0), height); + assert_eq!( + rows.len(), + quiet.len(), + "a queue drawn is not a frame grown", + ); + assert_eq!( + prompt, + row_of(&quiet, io_cli::composer::PROMPT.trim_end()), + "and the composer keeps the row it has when nothing is queued at all", + ); +} + +/// N2 — a session drawing a queue is the same session it was: no alternate +/// screen, no clear, eighty columns, every frame inside synchronized output. +#[test] +fn n2_the_queue_holds_at_eighty_columns_inside_the_synchronized_pair() { + let (mut screen, recorder) = support::screen_of(80, 24, TALL); + // Two frames that differ, because a frame identical to the one on screen is + // not drawn at all — see `tests/frames.rs` — and a skipped frame would read + // here as a missing wrapper. + for depth in [1, 7] { + let app = with_queue(depth); + screen + .draw(|frame| app.render(frame, frame.area())) + .expect("frame"); + } + let viewport = screen.viewport_text().to_string(); + drop(screen); + + let text = recorder.text(); + for (name, sequence) in support::FORBIDDEN { + assert!( + !text.contains(sequence), + "drawing the queue emitted {name} ({})", + sequence.escape_debug(), + ); + } + assert!(!text.contains("\x1b[2J"), "the queue cleared the display"); + assert!( + !text.contains("\x1b[3J"), + "the queue erased the scrollback, which is where the transcript lives", + ); + let begins = text.matches("\x1b[?2026h").count(); + assert_eq!(begins, 2, "one begin-synchronized-update per frame"); + assert_eq!( + text.matches("\x1b[?2026l").count(), + begins, + "every begin is closed by an end", + ); + for row in viewport.split('\n') { + assert!( + row.chars().count() <= 80, + "no row is wider than the terminal it was drawn for: {row:?}", + ); + } +} + +// --------------------------------------------------------------------------- +// The arithmetic, without a frame +// --------------------------------------------------------------------------- + +/// The oldest are the rows that are kept, and what did not fit is counted. +#[test] +fn what_does_not_fit_is_a_count_and_never_a_silence() { + let waiting: Vec = (0..9).map(|at| format!("prompt {at}")).collect(); + + let rows = queue::rows(&waiting, 40, 4, &UNICODE); + assert_eq!( + rows.len(), + 4, + "it draws into the rows it was given, and no more" + ); + assert!(rows[0].contains("prompt 0"), "{rows:#?}"); + assert!(rows[2].contains("prompt 2"), "{rows:#?}"); + assert!( + rows[3].contains('6'), + "the last row says how many did not fit: {rows:#?}", + ); + + // Room for everything is everything, with no count appended. + let all = queue::rows(&waiting, 40, 9, &UNICODE); + assert_eq!(all.len(), 9); + assert!(all[8].contains("prompt 8"), "{all:#?}"); + + // And the ASCII set spells the elision its own way, without changing what it + // means. + let ascii = queue::rows(&waiting, 40, 2, &ASCII); + assert_eq!(ascii.len(), 2); + assert!(ascii[1].contains("8 more"), "{ascii:#?}"); + assert!(ascii.iter().all(|row| row.is_ascii()), "{ascii:#?}"); +} + +/// No row is ever wider than the width it was fitted to, and nothing is drawn +/// into no rows at all. +#[test] +fn a_row_is_never_wider_than_the_width_it_was_given() { + let long = "a prompt long enough to need cutting at any width a terminal has, \ + twice over, with no natural break in it"; + let waiting = vec![long.to_string(), "short".to_string()]; + + for width in [10_u16, 24, 80] { + for row in queue::rows(&waiting, width, 4, &UNICODE) { + assert!( + row.chars().count() <= usize::from(width), + "a row of {} characters at width {width}: {row:?}", + row.chars().count(), + ); + } + } + + assert!(queue::rows(&waiting, 80, 0, &UNICODE).is_empty()); + assert!(queue::rows(&[], 80, 4, &UNICODE).is_empty()); +} + +/// A queued prompt with newlines in it is still one row. +/// +/// The one way a surface measured in rows can push the composer off the frame +/// anyway: a `Line` handed text with a `\n` in it draws rows the layout never +/// budgeted for. +#[test] +fn a_multi_line_prompt_is_drawn_as_one_row() { + let waiting = vec!["first line\nsecond line\n\tthird".to_string()]; + let rows = queue::rows(&waiting, 80, 4, &UNICODE); + assert_eq!(rows.len(), 1); + assert!(!rows[0].contains('\n'), "{rows:#?}"); + assert!(!rows[0].contains('\t'), "{rows:#?}"); + assert!( + rows[0].contains("first line second line third"), + "{rows:#?}" + ); +} diff --git a/tests/servers.rs b/tests/servers.rs index 19fa44b..e9b1573 100644 --- a/tests/servers.rs +++ b/tests/servers.rs @@ -217,7 +217,10 @@ fn f5_the_panel_reads_the_configuration_and_never_the_events_for_its_rows() { fn detail(config: &Config, observed: &Observed, id: &str) -> String { let list = servers::servers(config, observed); let rows = servers::rows(&list); - let at = list.iter().position(|s| s.id == id).expect("a configured id"); + let at = list + .iter() + .position(|s| s.id == id) + .expect("a configured id"); rows[at].detail.clone().expect("every row carries a detail") } diff --git a/tests/status.rs b/tests/status.rs index 56d1398..de1c559 100644 --- a/tests/status.rs +++ b/tests/status.rs @@ -1893,3 +1893,98 @@ fn f8_status_names_the_home_in_force_and_the_word_that_decided_it() { std::env::remove_var("IO_CONFIG"); std::env::remove_var("IO_CONFIG_HOME"); } + +/// **N3 — the queue depth is on every renderer that draws the status.** +/// +/// `Status` has three renderers that do not share a body: `fields` feeds `line` +/// and nothing else, `footer` is hand-built beside it, and `Status::render` +/// takes the **footer** on any terminal seven rows or taller — which is to say +/// on every terminal an operator actually has. So a field is not "on the status +/// line" because `fields` composes it; it is on the status line when both forms +/// draw it, in the same words, and the only way to know that is to read both. +/// +/// **This is the test that kills the sabotage.** Add the depth to `fields` alone +/// and the `Status::line` arm below stays green while the `Status::footer` arm +/// goes red — on the renderer the binary draws, which is precisely how 0.12.0 +/// shipped a planning mode that was nowhere on screen in a live capture. +/// +/// The spelling is asserted against `Status::queued_left` rather than against a +/// literal repeated three times, so the one place that composes it is the one +/// place a rename has to touch: a renderer that grew its own `format!` would +/// fail here even if both forms happened to say something. +#[test] +fn n3_the_queue_depth_is_drawn_by_the_line_and_by_the_footer_alike() { + let mut status = Status::new("anthropic/claude-sonnet-4.5"); + + // Nothing typed ahead, so nothing said. Both forms, because "absent at zero" + // is a claim about what reaches a terminal and not about one code path. + assert_eq!( + status.queued_left(), + None, + "a session nobody typed ahead of composed a depth", + ); + for (renderer, text) in both_renderers(&status) { + assert!( + !text.contains("queued"), + "{renderer} drew a queue on a session with none: {text:?}", + ); + } + + status.queued_prompts = 3; + let spelling = status + .queued_left() + .expect("three waiting prompts are a queue"); + assert_eq!(spelling, "queued 3", "the depth is spelled once, plainly"); + for (renderer, text) in both_renderers(&status) { + assert!( + text.contains(&spelling), + "{renderer} does not say what is waiting behind this turn: {text:?}", + ); + } + + // And back down to nothing when the queue drains, on both forms — a count + // that never returns to absent would leave every session that ever queued a + // prompt carrying the field for the rest of its life. + status.queued_prompts = 0; + for (renderer, text) in both_renderers(&status) { + assert!( + !text.contains("queued"), + "{renderer} kept a drained queue on screen: {text:?}", + ); + } +} + +/// **N3 — the queue belongs to the session and outlives the run.** +/// +/// `Status::forget_run` clears the per-run facts when the conversation under the +/// line changes, and the depth is deliberately not one of them. The queue itself +/// is `App::prompts` and `forget_run` cannot reach it — so a count blanked here +/// would not drop a single prompt, it would only stop reporting them, and they +/// would still fire a turn each under a line that had just said there were none. +/// +/// Sabotage: clear `queued_prompts` in `forget_run` beside `jobs` — under which only +/// this test fails, and it fails in the one state where the operator has typed +/// ahead and then moved the conversation, which is exactly when a prompt firing +/// unannounced is least welcome. +#[test] +fn n3_the_queue_depth_survives_forgetting_the_run() { + let mut status = Status::new("anthropic/claude-sonnet-4.5"); + status.queued_prompts = 2; + // A run-scoped neighbour, so this test states the difference rather than + // just the half it is about. + status.jobs = 1; + + status.forget_run(); + + assert_eq!(status.jobs, 0, "a background job belongs to its run"); + assert_eq!( + status.queued_prompts, 2, + "the prompts the operator typed are still going to run", + ); + for (renderer, text) in both_renderers(&status) { + assert!( + text.contains("queued 2"), + "{renderer} stopped saying what is still waiting: {text:?}", + ); + } +} diff --git a/tests/steer.rs b/tests/steer.rs new file mode 100644 index 0000000..b68ab88 --- /dev/null +++ b/tests/steer.rs @@ -0,0 +1,327 @@ +//! F5 — a queued line reaches the turn that is still running, on both arms. +//! +//! **What this file can and cannot prove, stated first, because the gap is the +//! whole reason the file is shaped like this.** +//! +//! A delivered steer emits no observer event. There is no `EventKind::Steered`; +//! io-harness records a `steered` row in the run's own context trace and pushes +//! the operator's words into the ledger as an ordinary `Observation`. So nothing +//! that arrives through `io_cli::bridge` says a message landed, and an +//! implementation that reported success on `Steer::say` returning `Ok` would be +//! indistinguishable, on screen and in this file, from one that works. `Ok` means +//! the channel took the words. It does not mean a step read them. +//! +//! That leaves three things provable here and one that is not: +//! +//! 1. **Both arms are handed an inbox.** Nothing under `tests/` links the binary, +//! so the driver's decisions are ones no test can drive — this file reads +//! `src/main.rs` instead, the instrument `tests/contract.rs` and +//! `tests/structure.rs` already use for exactly that reason. It is also the +//! assertion that kills the sabotage: keep the two `_observed` entry points +//! and send into an inbox nothing reads, and only these assertions fail. +//! 2. **The channel's own contract.** Messages arrive in the order they were sent, +//! an interrupt is carried in the same drain as the words beside it, and a send +//! into a channel with no reader left is an `Err` rather than a silence. +//! 3. **`/steer` survives the mid-turn refusal.** `App::compose` lets a `/` line +//! through while a turn holds the session, which is what makes the driver's arm +//! reachable at all. +//! +//! **Not provable without a network, and owed to the live run (T13):** that the +//! words reach the *model*. A real turn must be steered mid-flight and the run's +//! own trace must then show a `steered` context event, with the message in the +//! run's observations at a step after the one that was in flight. That is the only +//! evidence that closes F5, and it comes from `Store::context_events(run_id)` and +//! the ledger — never from the send. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use io_cli::app::{App, Command, Mode}; +use io_cli::theme::DARK; +use io_harness::Steer; + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +fn type_line(app: &mut App, text: &str) { + for character in text.chars() { + app.key(key(KeyCode::Char(character))); + } + app.key(key(KeyCode::Enter)); +} + +fn driver() -> String { + std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/main.rs")) + .expect("the driver") +} + +/// Whitespace removed, because rustfmt decides where an eight-argument call +/// breaks and an assertion about where a newline sits is an assertion about +/// formatting — it would go quietly blind the first time one of these grew an +/// argument. +fn squashed(text: &str) -> String { + text.chars().filter(|c| !c.is_whitespace()).collect() +} + +/// F5 — both arms take the caller's contract **and** a steer inbox. +/// +/// Through io-harness 0.66 that was a choice: `turn_bounded_observed` and +/// `turn_contained_bounded_observed` had no parameter for an inbox, so a session +/// wanting its own contract gave up steering to get one — which is the trade +/// io-cli made in 0.11.0 and paid for with a turn nobody could correct. 0.67.0 +/// opened both, and the `_steered` pair is positionally the same two calls with +/// the inbox appended. +/// +/// Sabotage: keep the two `_observed` entry points and send into an inbox nothing +/// reads. Only this test fails — and it is the one failure an operator cannot +/// detect from the screen, because the send still returns `Ok` and no event ever +/// said otherwise. +#[test] +fn f5_both_arms_are_handed_a_steer_inbox() { + let text = driver(); + let flat = squashed(&text); + + assert!( + flat.contains( + "session.turn_bounded_steered(&contract,provider,store,policy,&approver,&observer,\ + &inbox" + ), + "the flat arm takes the contract and the inbox", + ); + assert!( + flat.contains( + "session.turn_contained_bounded_steered(&contract,provider,store,policy,&approver,\ + caps,&observer,&inbox" + ), + "and so does the contained one, which is the arm that could not have one at all before \ + io-harness 0.67.0", + ); + + // The negative half, and the sabotage's own shape: an entry point that takes + // no inbox cannot deliver a word, however successful the send looked. + for deaf in ["turn_bounded_observed(", "turn_contained_bounded_observed("] { + assert!( + !text.contains(deaf), + "{deaf} takes no steer inbox, so a turn driven through it hears nothing", + ); + } + + // One inbox per turn, because `SteerInbox` is not `Clone` and one turn reads + // one inbox: two would each get some of the operator's messages and neither + // would get all of them. + assert_eq!( + text.matches("Steer::channel()").count(), + 1, + "a turn builds exactly one inbox, and both arms are handed that one", + ); +} + +/// F5 — `Ctrl+C` did not move onto the steer channel. +/// +/// Both arms now hold an inbox, so `Steer::interrupt` became a second way to +/// reach `RunOutcome::Cancelled` at the same step boundary. The stop key stays on +/// the observer's flag anyway: the two paths are recorded by different code in +/// io-harness, an operator cannot tell them apart from the screen, and this is the +/// one key this product refuses to let a configuration file rebind. The inbox +/// carries the operator's words; the flag carries their stop. +/// +/// Sabotage: route the interrupt through `Steer::interrupt` — under which this +/// test fails, and `tests/interrupt.rs` fails with it for no gain anybody can see. +#[test] +fn f5_the_stop_key_stays_on_the_observers_flag() { + let text = driver(); + assert!( + !text.contains("steer.interrupt()"), + "`Ctrl+C` is the canceller's, and giving it a second mechanism changes which path records \ + the outcome", + ); + assert!( + !text.contains("steer.fold()"), + "a fold is a third thing an operator can send and this release does not offer it; a call \ + with no key behind it is a path nothing can reach", + ); +} + +/// F5 — what is sent is what was queued, and it is sent because the operator +/// asked. +/// +/// **The open question this release had to answer.** A line typed mid-turn must +/// not reach the agent by itself. A delivered steer emits no event, so an +/// interface cannot show that the agent heard it — a line sent by default would +/// leave the screen with no echo, no cell and no confirmation, which is the same +/// shape as the keystroke 0.16.0 lost and `App::compose` has just been fixed to +/// stop losing. A queue is visible state a surface can draw; a steer is not. And +/// `Steer::say` has no undo, so an operator writing themselves a note while an +/// agent works would be steering it, once per stray sentence. +/// +/// So the queue keeps its promise — three lines are three turns — and `/steer` is +/// the one word that spends it differently. +/// +/// Sabotage: send every queued prompt as it is typed. Only this test fails, and +/// it fails on the count: a second `steer.say(` is a second path, and a path +/// nobody asked for is the accident. +#[test] +fn f5_the_queue_is_sent_on_the_operators_word_and_not_before() { + let text = driver(); + assert_eq!( + text.matches("steer.say(").count(), + 1, + "one send site. Two means something else in the driver can steer a turn, and the operator \ + did not ask it to", + ); + + // And that one site reads the queue rather than a line of its own: F5 is + // about a *queued* line reaching the running turn. + let arm = text + .split_once("line.trim() == \"steer\"") + .expect("the driver answers /steer while a turn is running") + .1; + let arm = arm + .split_once("// Refused with a sentence") + .expect("the arm sits above the refusal it is an exception to") + .0; + assert!( + arm.contains("app.next_queued_prompt()"), + "what is sent is what was queued: {arm}", + ); + assert!( + arm.contains("steer.say("), + "and it goes into the inbox the two arms were handed: {arm}", + ); + // Said, never claimed delivered. Nothing on the way in confirms a message + // landed, so the sentence may not say it did. + assert!( + !arm.contains("delivered"), + "`Ok` from a send means the channel took the words, not that a step read them", + ); +} + +/// F5 — the words are not lost when the turn ends before a step reads them. +/// +/// The one delivery fact this interface can state is the negative one, and this +/// is where it is stated: `SteerInbox::pending` is public precisely so a caller +/// can drain an inbox it is no longer handing to a turn. +#[test] +fn f5_the_driver_says_what_the_turn_never_read() { + let text = driver(); + assert!( + text.contains("inbox.pending()"), + "a message still in the inbox when the turn returned is a message nobody read, and the \ + operator is told so rather than discovering it later", + ); + assert!( + text.contains("not delivered"), + "and it is said in those words", + ); +} + +/// F5 — `/steer` reaches the driver while a turn holds the session. +/// +/// `App::compose` refuses a slash command mid-turn with a sentence *in the +/// driver*, not in the library — the `/` arm deliberately keeps falling through. +/// That fall-through is what makes the driver's arm reachable, and it is the only +/// part of this key path a test can link against. +#[test] +fn f5_a_slash_command_still_reaches_the_driver_mid_turn() { + let mut app = App::new(DARK, "anthropic/claude-sonnet-4"); + + // A prompt starts the turn. + type_line(&mut app, "refactor the parser"); + app.started(); + assert_eq!(app.mode(), Mode::Running); + + // A plain line typed while it runs is held, which is the thing `/steer` + // spends. (`tests/queue.rs` owns the queue's own behaviour; this is only the + // premise — without something waiting there is nothing to send.) + type_line(&mut app, "actually, prefer the smaller diff"); + assert_eq!(app.queued_prompts().len(), 1); + assert_eq!( + app.queued_prompts()[0], + "actually, prefer the smaller diff", + "the line the operator wants steered is waiting", + ); + + // And the word that spends it is not swallowed by the library: the driver's + // arm is the only place that answers it, and it can only answer what reaches + // it. + for character in "/steer".chars() { + app.key(key(KeyCode::Char(character))); + } + assert_eq!( + app.key(key(KeyCode::Enter)), + Command::Slash("steer".into()), + "a slash command still reaches the driver while a turn holds the session", + ); + // And it did not run itself on the way past: the queue is spent in the + // driver, where the inbox is. + assert_eq!(app.queued_prompts().len(), 1); +} + +/// F5 — the channel delivers the operator's words in the order they were said. +/// +/// One `Observation` per message, in order, because that is what the model reads. +/// Joined into one they would be a paragraph the operator never wrote. +#[test] +fn f5_messages_arrive_in_the_order_they_were_sent() { + let (steer, inbox) = Steer::channel(); + for said in ["first", "second", "third"] { + steer.say(said).expect("the inbox is alive"); + } + + let steering = inbox.pending(); + assert_eq!(steering.messages, ["first", "second", "third"]); + assert!(!steering.interrupted, "saying something is not stopping"); + assert!(!steering.fold, "and it is not a fold"); + + // Drained is drained: what a turn has read, a later read does not see again. + assert!(inbox.pending().messages.is_empty()); +} + +/// F5 — an interrupt is carried alongside the words sent before it, and the drain +/// answers it first. +/// +/// The precedence is io-harness's — `drain_steer` returns `RunOutcome::Cancelled` +/// before it looks at the fold or pushes a message — and this asserts the half a +/// caller can observe: both reach the same drain, so the trace holds both. An +/// operator who typed a correction and then hit stop sent both, and neither is +/// swallowed on the way. +#[test] +fn f5_an_interrupt_rides_the_same_drain_as_the_words_before_it() { + let (steer, inbox) = Steer::channel(); + steer.say("try the other approach").expect("alive"); + steer.fold().expect("alive"); + steer.interrupt().expect("alive"); + + let steering = inbox.pending(); + assert!(steering.interrupted, "the stop reached the same boundary"); + assert_eq!( + steering.messages, + ["try the other approach"], + "and it did not swallow what was said before it", + ); + assert!(steering.fold, "nor the fold, which the interrupt outranks"); +} + +/// F5 — a send with nobody left to read it is an error, not a silence. +/// +/// The failure this whole file is arranged around is a send that reports success +/// while the agent never sees a word of it. io-harness refuses to be that: when +/// the inbox is gone, `say` says so. +#[test] +fn f5_a_send_after_the_turn_has_ended_is_refused() { + let (steer, inbox) = Steer::channel(); + steer.say("in time").expect("the turn is still listening"); + drop(inbox); + + let error = steer + .say("too late") + .expect_err("a message nobody will read must not report success"); + let said = error.to_string(); + assert!( + !said.is_empty() && said.contains("ended"), + "the operator is told their correction went nowhere: {said}", + ); + assert!( + steer.interrupt().is_err() && steer.fold().is_err(), + "and the other two go the same way", + ); +} From 7b5c117799242eb40ce119d5decc1d439587210a Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Wed, 26 Aug 2026 08:51:55 +0530 Subject: [PATCH 06/12] docs(tests): the queue surface's own notes name the row it actually takes --- tests/queue_surface.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/queue_surface.rs b/tests/queue_surface.rs index d613a10..0587e7c 100644 --- a/tests/queue_surface.rs +++ b/tests/queue_surface.rs @@ -15,9 +15,13 @@ //! N2 is a refusal of a row. The viewport is subtracted from the terminal and //! the transcript is the terminal's own scrollback, so a surface that claimed a //! row per queued line would walk the conversation upward one row for every line -//! typed against it. The rows come out of the composer's allowance instead — -//! the way `Fleet::render` takes them — and the frame is the same height with -//! nine prompts waiting as with none. +//! typed against it. The rows are borrowed instead, and the frame is the same +//! height with nine prompts waiting as with none: the composer's spare rows +//! where a taller terminal has any — the way `Fleet::render` takes them — and, +//! at the height a running turn is actually drawn at, the blank above the +//! activity line, which the layout's own argument describes as carrying +//! nothing. That is `US-IO-CLI-0.17.0-I02`, and the reason it exists is that +//! the composer's allowance at that height *is* the composer's floor. //! //! Asserted on the rendered viewport rather than on the rectangles wherever a //! reader's answer differs from the renderer's arithmetic: a test that recomputed @@ -116,8 +120,10 @@ fn row_of(rows: &[String], needle: &str) -> usize { /// Twelve rows leaves the composer five — the streaming row, the blank, the /// activity line, the rule and three rows of footer take the rest — of which one /// is the composer's own floor and four can be lent. It is not -/// `term::VIEWPORT_HEIGHT`, and that is a finding rather than a convenience: see -/// the note on `n2_the_surface_draws_nothing_when_the_composer_has_no_row_to_spare`. +/// `term::VIEWPORT_HEIGHT`, and the difference is a finding rather than a +/// convenience: at the running viewport there are no spare rows at all and the +/// surface is drawn in the borrowed blank instead, with one row rather than +/// four. See `n2_the_surface_is_visible_at_the_running_viewport_and_costs_it_no_height`. const TALL: u16 = 12; // --------------------------------------------------------------------------- From e8785d3610c11429de1e14c6b8a4b94c24e944d5 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Wed, 26 Aug 2026 09:15:58 +0530 Subject: [PATCH 07/12] feat(queue,compact): edit what is waiting, and fold when you say so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The queue is editable, `/compact` is real, and the interrupt has a gate. **Editing.** The mark lives on a `Cursor` in the queue's own module and not on `App`: a selection is a fact about the surface drawing it, meaningless with the surface shut, read by nothing but the rows — and on `App` it would be a fifth field about a list that already has three, which every mutation would then have to remember to keep honest. The verbs take the queue by reference, so `App` is still its one owner. The index is stored raw and every read clamps, because the queue drains from the front underneath it. `Enter` at an empty prompt takes the marked line OUT of the queue and into the composer — out rather than copied, so a turn ending mid-edit cannot run it twice. `Enter` puts it back at its own position, emptied it is a drop, and `Esc` restores the text as it was taken, which is 0.13.1's rule in the one place this release can honour it. Because the take needs an empty prompt, a half-typed line is never overwritten. A turn ending under an edit forgets the position and not the text: re-inserting would put a second copy behind a drain that is about to start, and both would run. `Up` stays prompt history. The surface's arrows are scoped to the open surface AND an empty prompt, so a recall in progress and the arrows inside a multi-line prompt never reach it at all. Bound at the bare composer they would have worked perfectly for anyone with something queued and silently cost history to everyone else — a feature nobody asked to trade, broken by a release about a different one. **`/compact`.** The roadmap said io-harness had no fold-now call and would have to lower the threshold and hope. It has two: `TaskContract::fold_now` for the next turn and `Steer::fold()` for the turn already running. So a request is a request and a fold is a fold, and one type carries both states because the defect this exists to prevent is a request wearing an outcome's words. Four documented conditions make an accepted request fold nothing, and the request is spent under all four. Two can be predicted — the timing, and folding configured off, which is read from the contract and said before anything leaves the process. Two can only be observed: an interrupt at the same boundary wins, and a conversation shorter than the fold's floor has no prefix to stand in for. So nothing but `EventKind::Compacted` may say a fold happened, and when the turn ends without one the line says the request was spent rather than that a fold occurred. What the summary replaced is named from the store, which keeps the observations a fold only stops sending. **The interrupt.** Eight new tests pin every branch of it, including the one no test could reach before: the mechanism. It is a source gate over the driver with comments stripped — the driver names `Steer::interrupt` in the comment saying why it is not used — asserting the cancellation flag is what the key sets and that the inbox is not, plus the live half through the real bridge so the gate does not rest on a variable's name. `tests/interrupt.rs` said in its own module doc that `Ctrl+C` "must reach `Steer::interrupt`", which was the opposite of what the driver has ever done. **Gates.** The clock rule now covers `SystemTime::now` and `.elapsed()` as well as `Instant::now` — the criterion always named three and the gate swept for one — walks `src/` recursively rather than one level, and refuses an aliased or glob-imported clock, scoped to import lines so prose arguing about time does not redden. The dependency sweep now reads build-dependencies and every target-conditional table: `nix` is on the forbidden list and cfg-guarded is exactly how a sandbox crate arrives. Dev-dependencies are pinned separately. Two gates were wrong rather than the code, and both are corrected here rather than worked around: the decorator gate matched a contiguous string that rustfmt splits across two lines, and a steer gate asserted a fold was not offered — both written against a half-built tree. And two tests asserted the wrong thing about behaviour older than this release. A drop notice naming which line went is the product working; `Up` with text typed has always recalled history and stashed the draft. Both now assert what F3 is actually owed — that the surface did not take a key belonging to the composer. --- README.md | 1 + src/app.rs | 141 ++++++- src/commands.rs | 39 +- src/compact.rs | 259 +++++++++++++ src/lib.rs | 1 + src/main.rs | 112 ++++++ src/queue.rs | 379 +++++++++++++++++-- tests/commands.rs | 3 + tests/compact.rs | 332 +++++++++++++++++ tests/dependencies.rs | 100 ++++- tests/interrupt.rs | 415 ++++++++++++++++++++- tests/palette.rs | 16 +- tests/queue_edit.rs | 841 ++++++++++++++++++++++++++++++++++++++++++ tests/steer.rs | 16 +- tests/timing.rs | 207 +++++++++-- 15 files changed, 2766 insertions(+), 96 deletions(-) create mode 100644 src/compact.rs create mode 100644 tests/compact.rs create mode 100644 tests/queue_edit.rs diff --git a/README.md b/README.md index 0e06d65..419daa1 100644 --- a/README.md +++ b/README.md @@ -338,6 +338,7 @@ above a row that ranked there for reasons having nothing to do with it. | `/status` | commit the whole session state into the scrollback | | `/context` | what is in the model's window, read from the request that carried the turn | | `/steer` | send what is queued into the turn that is already running | +| `/compact` | fold this conversation into a summary, at the next step | | `/expand` | commit the last step's full detail into the scrollback | | `/fleet` | show the children this turn has spawned | | `/mcp` | the MCP servers configured, and what this session has seen of each | diff --git a/src/app.rs b/src/app.rs index b131b1c..2f3a5fa 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,7 +7,7 @@ use std::time::Duration; -use crossterm::event::{KeyCode, KeyEvent}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use io_harness::RunEvent; use ratatui::layout::Rect; use ratatui::text::Line; @@ -220,6 +220,16 @@ pub struct App { /// [`App::queue_open`] is the predicate every caller asks, and it is the /// three facts together: opened, still queued, and a turn to be queued behind. queue_open: bool, + /// Where the operator is inside `prompts`, and which line they have taken out + /// of it to edit. + /// + /// **The queue is here and the mark is there, and that split is the point.** + /// `prompts` is a fact about the session — the driver drains it, a turn runs + /// off it, and it outlives every surface. A mark is a fact about the surface + /// drawing it: meaningless with the surface shut, read by nothing but the + /// rows. See [`crate::queue::Cursor`], whose verbs take `&mut Vec` so + /// that this stays the one owner of the queue itself. + queue: crate::queue::Cursor, /// Every image attached in this session, by path, oldest first. /// /// The index into it is the number in `[Image #1]`, and the path is all that @@ -293,6 +303,7 @@ impl App { queued: Vec::new(), prompts: Vec::new(), queue_open: false, + queue: crate::queue::Cursor::default(), images: Vec::new(), turn_rows: 0, echo_rows: 0, @@ -803,6 +814,13 @@ impl App { // still there — `/fleet` reopens it, and every spawn, refusal and report // is in the transcript — but the prompt comes back on its own. self.fleet_open = false; + // An edit lapses with the turn it was made against. The line itself stays + // in the composer, where the operator can see it and send it: putting it + // back would queue a second copy behind the drain that is about to start, + // and both would run. What is dropped is the *position*, which is the part + // that goes stale — a slot remembered across a drain points at somebody + // else's line. + self.queue.lapsed(); // A question outlives its run only as a stuck overlay over a session that // has moved on. Dropping it is the denial — see `Ask` — and the run it // belonged to has already ended, so there is nobody left to tell. @@ -1318,26 +1336,118 @@ impl App { _ => {} } } - // The queue surface owns exactly one key, and only while it is up: `Esc` - // shuts it. Everything else falls through to the match below — `Ctrl+C` - // still interrupts, the arrows still edit, and the composer still takes - // typing, which it has to, because typing is how the *next* line joins - // the queue this is drawing. + // The queue surface owns four keys, and only while it is up: the arrows + // move the mark at an empty prompt, the shifted arrows move the marked + // line, `Enter` at an empty prompt takes that line into the composer and + // puts it back when it is done, and `Esc` shuts the surface. Everything + // else falls through to the match below — `Ctrl+C` still interrupts and + // the composer still takes typing, which it has to, because typing is how + // the *next* line joins the queue this is drawing. + // + // **Scoped to the open surface, and that scope is the whole binding.** + // `Up` at the first line of the composer is prompt history and has been + // since it was documented — `commands::KEYS` carries the row and + // `tests/docs.rs` mirrors it into the README. Bound at the bare composer + // these arrows would work perfectly for an operator with something queued + // and silently cost history to everyone else: a feature nobody asked to + // trade, broken by a release about a different one. Two guards keep it + // narrow — the surface has to be open and the prompt has to be empty — so + // a recall that is *continuing*, and the arrows inside a multi-line prompt + // being written, never reach this block at all. `Esc` hands both back. + // + // An edit in flight keeps these keys live even when the take emptied the + // queue and closed the surface: otherwise the `Esc` cancelling an edit of + // the last queued line would fall through and interrupt the turn. // // It costs the turn one extra `Esc`, and that is the right way round. // While a turn runs `Esc` stops it, and an operator who has just been - // shown a list has a reading of that key which is not "stop the run" — - // so the first press answers the surface and the second reaches the turn. - // The same trade the fleet view makes, below it for the same reason: the - // view that was opened by a key is the one that should close first. + // shown a list has a reading of that key which is not "stop the run" — so + // the first press answers the surface and the second reaches the turn. The + // same trade the fleet view makes, below it for the same reason: the view + // that was opened by a key is the one that should close first. // // Guarded on the *taken* arming rather than on `self.armed`, which this // function emptied a few lines above: `Esc` can be the second key of a - // rebound chord, and a surface that stole it would be answering a - // sequence the operator was half way through. - if self.queue_open() && key.code == KeyCode::Esc && armed.is_none() { - self.queue_open = false; - return Command::None; + // rebound chord, and a surface that stole it would be answering a sequence + // the operator was half way through. + if self.queue_open() || self.queue.editing().is_some() { + let dash = self.theme.glyphs.dash; + match key.code { + KeyCode::Up | KeyCode::Down + if self.queue.editing().is_none() && self.composer.is_empty() => + { + let delta = if key.code == KeyCode::Up { -1 } else { 1 }; + // Shifted moves the line, bare moves the mark. `false` means + // the key was never ours — nothing marked, or an end of the + // list — and it falls through to the composer rather than + // being swallowed. + let moved = if key.modifiers.contains(KeyModifiers::SHIFT) { + self.queue.reorder(delta, &mut self.prompts) + } else { + self.queue.move_by(delta, self.prompts.len()) + }; + if moved { + return Command::None; + } + } + // Finishing an edit is not sending a turn. Above the arm below and + // above the match: a `Reply::Submitted` reaching `compose` would + // queue the line a second time. + KeyCode::Enter if self.queue.editing().is_some() => { + let text = self.composer.text(); + self.composer.clear(); + let put = self.queue.put_back(&mut self.prompts, &text); + self.status.queued_prompts = self.prompts.len(); + let said = match put { + Some(crate::queue::Put::Kept(at)) => { + format!("line {} edited {dash} it runs in its own place", at + 1) + } + Some(crate::queue::Put::Dropped(was)) => format!( + "dropped {}{}{} {dash} {} still queued", + self.theme.glyphs.quote_open, + crate::picker::fit(&was, 32, &self.theme.glyphs), + self.theme.glyphs.quote_close, + self.prompts.len(), + ), + None => unreachable!("the arm is guarded on an edit in flight"), + }; + self.say(Tone::Muted, said); + return Command::None; + } + // Only at an empty prompt, so an edit can never start on top of a + // half-typed line: with text in the composer `Enter` goes on + // meaning what it has meant all release, which is *queue this*. + KeyCode::Enter if self.composer.is_empty() => { + if let Some(text) = self.queue.take(&mut self.prompts) { + self.composer.set(&text); + self.status.queued_prompts = self.prompts.len(); + let at = self.queue.editing().unwrap_or(0); + self.say( + Tone::Muted, + format!( + "editing line {} {dash} Enter puts it back where it was, \ + Esc leaves it as it was", + at + 1 + ), + ); + return Command::None; + } + } + KeyCode::Esc if armed.is_none() => { + if self.queue.cancel(&mut self.prompts).is_some() { + self.composer.clear(); + self.status.queued_prompts = self.prompts.len(); + self.say( + Tone::Muted, + format!("edit cancelled {dash} the line is as it was"), + ); + } else { + self.queue_open = false; + } + return Command::None; + } + _ => {} + } } match hit { Some(Hit::Fire(Action::Interrupt)) => self.interrupt_or_quit(), @@ -1701,6 +1811,7 @@ impl App { if queue_rows > 0 { crate::queue::render( &self.prompts, + self.queue.selection(self.prompts.len()), frame, Rect { height: queue_rows, diff --git a/src/commands.rs b/src/commands.rs index ee79be7..42a3ed6 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -148,6 +148,13 @@ pub const COMMANDS: &[(&str, &str)] = &[ "/steer", "send what is queued into the turn that is already running", ), + // Beside `/steer` because it is the same kind of word: something an operator + // says *to* a turn rather than about one, and the only other command whose + // effect is decided by whether anything is running. + ( + "/compact", + "fold this conversation into a summary, at the next step", + ), ("/copy", "put the last answer on the system clipboard"), ( "/copy diff", @@ -248,7 +255,9 @@ pub const GROUPS: &[(Group, &[&str])] = &[ ), ( Group::Turn, - &["/model", "/contain", "/plan", "/profile", "/steer"], + &[ + "/model", "/contain", "/plan", "/profile", "/steer", "/compact", + ], ), ( Group::Inspect, @@ -687,6 +696,21 @@ pub enum Action { /// undo, which would make every note typed to oneself mid-turn an /// instruction to the agent. Steer, + /// Fold the conversation into a summary and carry on. + /// + /// **Two triggers behind one word, chosen by whether a turn is running.** A + /// running turn is asked through `Steer::fold`, which lands at its next step + /// boundary; an idle prompt has no turn to ask, so the request rides the next + /// turn's contract as `TaskContract::fold_now` and folds at that turn's first + /// step. The driver answers the first case before [`parse`] is ever called — + /// the same shape [`Action::Steer`] has — so this action is what an idle + /// prompt means. + /// + /// **What it must never do is report the fold.** io-harness documents four + /// conditions under which an accepted request folds nothing, and the request + /// is spent under all of them, so the only thing that says a fold happened is + /// `EventKind::Compacted`. [`crate::compact`] is where that rule lives. + Compact, /// Commit what the model's window actually held, section by section. /// /// Read off the request that carried the last turn and never reconstructed: @@ -897,6 +921,19 @@ pub fn parse(input: &str, keys: &Keys, theme: &Theme) -> Action { // not "there is no such command", which is what an unregistered name // gets and would be a lie about a command the palette lists. "steer" => Action::Steer, + // **One spelling, for the reason `/status` has one.** The driver's + // mid-turn arm matches the literal word `compact` before `parse` is + // reached — the shape `/steer` already has — so a second name here would + // be a name that worked at an idle prompt and did nothing mid-turn, which + // is the worst kind of alias. `/fold` is deliberately not taken: it is the + // word io-harness uses internally, and nobody has typed it at a prompt + // yet. + // + // It reaches this arm at an idle prompt, where the answer is not "there is + // no turn". A request made here is honoured at the *next* turn's first + // step, through the contract, so the idle case is a real feature rather + // than a refusal with a sentence. + "compact" => Action::Compact, // **`/attach` is gone, and 0.13.1 is where it went.** A picture is // attached by dropping it on the prompt or pasting it — which is what an // operator already does in every other window they talk to a model in — diff --git a/src/compact.rs b/src/compact.rs new file mode 100644 index 0000000..4ac7ed4 --- /dev/null +++ b/src/compact.rs @@ -0,0 +1,259 @@ +//! `/compact` — asking io-harness to fold the conversation, and reporting the +//! fold rather than the request. +//! +//! **The whole module exists for one sentence in io-harness's own documentation**, +//! on [`Steer::fold`](io_harness::Steer::fold): an interface "must not report a +//! fold on the strength of having sent one — read the `Compacted` event instead." +//! Everything below is that sentence made structural. A request is one thing +//! ([`Said::asked`]); a fold is another ([`Said::folded`]), and it can only be +//! built out of an [`EventKind::Compacted`] that actually arrived. There is no +//! path from the first to the second, which is what stops the obvious bug: an +//! interface that says *folded* the moment `Steer::fold()` returns `Ok`, and is +//! then wrong on every conversation too short to have anything to fold. +//! +//! **Two triggers, one word.** io-harness reads a fold request in two places and +//! io-cli reaches both from the same `/compact`: +//! +//! - Mid-turn, `Steer::fold()` lands at the turn's **next step boundary** — a tool +//! call in flight is not a safe place to change the conversation out from under +//! the model. +//! - At an idle prompt there is no turn to steer, so the request rides the next +//! turn's contract as `TaskContract::fold_now`, which io-harness reads once at +//! that turn's **first step**, before it assembles its first request, and +//! consumes with `std::mem::take`. io-cli builds a fresh contract for every +//! turn, so the flag is naturally per-turn and nothing has to remember to clear +//! it. +//! +//! **Four documented ways a request that was accepted still folds nothing, and +//! this module can predict exactly two of them.** The split is the reason +//! [`Said`] has the variants it has: +//! +//! | condition | predicted, or observed | +//! |---|---| +//! | it is not immediate — the next boundary, not now | **predicted**: a property of the mechanism, so [`Said::Sent`] and [`Said::Armed`] say when rather than whether | +//! | it does not override an off setting | **predicted**, from the contract's own [`Compaction::enabled`] — nothing is sent at all, and [`Said::Off`] is said before anything leaves this process | +//! | it loses to an interrupt sent before the same boundary | **observed**: nothing here knows what the operator will press next. [`Said::Unfolded`] carries whether the turn was stopped, which is the one thing the driver does know afterwards | +//! | it does nothing when there is nothing to fold | **observed, and only by absence**: a conversation shorter than [`Compaction::keep_recent`] has no prefix a paragraph could stand in for, and the only evidence is that no `Compacted` event ever arrived. **The request is spent either way**, which the sentence says, because an operator who is not told that will wait for a fold that is never coming | +//! +//! A fifth condition is io-harness's and not this interface's problem: a fold does +//! not reach a spawned child, because a child's ledger is its own work with no +//! conversation seeded into it. Nothing here claims otherwise — the sentences talk +//! about *the turn*, never about the fleet. +//! +//! **What the summary replaced is recoverable, and it is the useful half of the +//! report.** `EventKind::Compacted` carries a step and two token figures and no +//! text; `Store::summaries` carries the paragraph and, in +//! [`Summary::folded`](io_harness::Summary::folded), how many observations from +//! the front of the ledger it stands in for. A fold deletes nothing from the +//! store, so those observations are still there to be read — which is why the +//! sentence can say what was replaced without any of it having been lost. + +use io_harness::{Compaction, EventKind, RunEvent, Store}; + +use crate::status::format_tokens; + +/// Everything `/compact` puts on the screen, and the one place a fold is claimed. +/// +/// **One type for the request and for the outcome, deliberately**, because the +/// bug this module exists to prevent is exactly a request wearing an outcome's +/// words. With both in one enum the difference is a variant rather than a turn of +/// phrase, [`Said::is_fold`] is the whole of the honesty rule in one line, and +/// `tests/compact.rs` can assert it without matching on prose. +/// +/// [`Said::Folded`] is the only variant that says a fold happened, and +/// [`Said::folded`] is the only constructor for it — it takes a [`RunEvent`] and +/// returns nothing at all unless that event is an [`EventKind::Compacted`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Said { + /// Folding is off for this session, so nothing was sent. + /// + /// The one condition that is known *before* the request exists, and the + /// reason it is worth knowing: `Compaction { at_share: 1.0, .. }` never folds + /// and no trigger reverses that, so a request sent under it would be spent for + /// nothing and reported as pending forever. + Off, + /// The request went to the turn that is running. It folds at the next step + /// boundary. + Sent, + /// There is no turn to steer, so the next turn's contract carries it and folds + /// at that turn's first step. + Armed, + /// `EventKind::Compacted` arrived: a fold happened. **The only variant that + /// says so.** + Folded { + /// The step whose assembly folded, from the event. + through_step: u32, + /// Estimated tokens the observation section held before the fold. + before: u64, + /// What it holds after it. A fold that bought nothing is visible here as + /// two numbers that barely differ, which is a truth worth showing rather + /// than rounding away. + after: u64, + /// How many observations from the front of the ledger the summary stands + /// in for, from the run's own `summaries` row — `None` where the row is + /// not readable, in which case the fold is still reported and the count + /// simply is not claimed. + replaced: Option, + }, + /// The turn ended and no `Compacted` event ever came. + /// + /// Not a failure and not an error: it is the honest report of the two + /// conditions nothing here can predict. `interrupted` separates them as far as + /// they can be separated — a turn the operator stopped lost the fold to the + /// stop, and a turn that ran to the end simply had nothing to fold. + Unfolded { + /// Whether the operator stopped this turn. + interrupted: bool, + /// How many of the newest observations a fold would have kept whole — + /// [`Compaction::keep`], floored at one, rather than the raw + /// `keep_recent`, because that floor is what io-harness actually applies. + keep_recent: usize, + }, +} + +impl Said { + /// What `/compact` does with the request, decided before anything is sent. + /// + /// `running` is whether a turn is in flight, which is the only thing that + /// chooses between the two triggers — and it is passed in rather than looked + /// up because this module has no session to ask. + /// + /// The off check comes first and is the reason this function takes a + /// [`Compaction`] at all. It is read off the contract that is about to run + /// (or that just ran), never recomposed from the configuration, for the reason + /// the status line reads its budgets off the contract: the setting in force is + /// the one the turn carries, and a second answer assembled here would be a + /// second opinion about a value io-cli does not own. + pub fn asked(compaction: Compaction, running: bool) -> Self { + if !compaction.enabled() { + Said::Off + } else if running { + Said::Sent + } else { + Said::Armed + } + } + + /// The fold, read out of the event that announced it and the row that holds + /// the paragraph. `None` for every other kind of event. + /// + /// **This is the only way a [`Said::Folded`] is made**, and it is why the + /// driver's arm can be one call: an interface that wanted to report a fold + /// early would have to fabricate an event, which is a thing somebody would + /// notice writing. + /// + /// The summary row is looked up by the event's own `through_step`, which is + /// the field io-harness documents the two as agreeing on, and the *newest* + /// match wins because a run whose fold was corrected reads the correction. A + /// missing row is not a missing fold: the event is the fact, the row is the + /// detail, and losing the second is a sentence with one fewer number in it. + pub fn folded(store: &Store, event: &RunEvent) -> Option { + let EventKind::Compacted { + through_step, + before_tokens, + after_tokens, + } = &event.kind + else { + return None; + }; + let replaced = store + .summaries(event.run_id) + .ok() + .and_then(|rows| { + rows.into_iter() + .rev() + .find(|row| row.through_step == *through_step) + }) + .map(|row| row.folded); + Some(Said::Folded { + through_step: *through_step, + before: *before_tokens, + after: *after_tokens, + replaced, + }) + } + + /// What to say when the turn is over and the event never arrived. + /// + /// Takes the [`Compaction`] rather than a number so the sentence names + /// [`Compaction::keep`] — the floored value io-harness actually keeps — rather + /// than a `keep_recent` of zero that would read as "a fold keeps nothing". + pub fn unfolded(compaction: Compaction, interrupted: bool) -> Self { + Said::Unfolded { + interrupted, + keep_recent: compaction.keep(), + } + } + + /// Whether this line tells the operator that a fold **happened**. + /// + /// True for exactly one variant. This is the honesty rule as a predicate, and + /// `tests/compact.rs` reads it on every arm — including the two where a + /// request was accepted and nothing folded, which is where the sabotage lands. + pub fn is_fold(&self) -> bool { + matches!(self, Said::Folded { .. }) + } + + /// The line itself. + /// + /// `dash` is the session's own separator glyph, passed in the way every other + /// sentence in the driver takes it — a module that reached for the Unicode em + /// dash directly would be the one surface that ignores `--plain` and + /// `NO_COLOR`'s ASCII glyph set. + pub fn line(&self, dash: &str) -> String { + match self { + // Says *nothing was sent*, and says it before anything is: this is the + // one condition worth knowing in advance, and the value of knowing it + // is that the operator is not left waiting on a request that could + // never have been honoured. + Said::Off => format!( + "folding is off for this session {dash} nothing was sent, and nothing would fold" + ), + // *When*, never *whether*. The step boundary is a property of the + // mechanism and safe to promise; the fold is not, and is not mentioned + // in the past tense anywhere on this arm. + Said::Sent => format!( + "asked {dash} the turn folds at its next step, and this says so when it does" + ), + Said::Armed => format!( + "asked {dash} the next turn folds at its first step, and this says so when it does" + ), + Said::Folded { + through_step, + before, + after, + replaced, + } => { + let cost = format!( + "{} tokens to {}", + format_tokens(*before), + format_tokens(*after) + ); + match replaced { + // What the paragraph stands in for, which is the half of the + // report the event alone cannot give. Nothing was deleted to + // make it: the observations are still in the run's store. + Some(count) => format!( + "folded {dash} {count} observations through step {through_step} are now \ + one paragraph; {cost}" + ), + None => format!("folded {dash} through step {through_step}; {cost}"), + } + } + // Neither of these claims to know which of the two unpredictable + // conditions happened beyond what the driver actually observed, and + // both say the request is gone — because an operator who thinks it is + // still queued will wait instead of asking again. + Said::Unfolded { + interrupted: true, .. + } => format!("stopped before the fold {dash} the request went with the turn"), + Said::Unfolded { + interrupted: false, + keep_recent, + } => format!( + "nothing to fold {dash} no fold was reported; the newest {keep_recent} \ + observations are kept whole, and the request is spent" + ), + } + } +} diff --git a/src/lib.rs b/src/lib.rs index de32d23..c4dab9d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,6 +31,7 @@ pub mod bridge; pub mod cli; pub mod clipboard; pub mod commands; +pub mod compact; pub mod complete; pub mod composer; pub mod configure; diff --git a/src/main.rs b/src/main.rs index 7885ff6..e78483d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -563,6 +563,8 @@ async fn loop_over Result>( // fan-out silently made every turn stop and plan first. It is the operator's // switch now, and nothing turns it on but `/plan on`. let mut planning = false; + // One `/compact` typed at an idle prompt, spent by the next turn that starts. + let mut fold_next = false; if let Some(caps) = &containment { let notice = settings::contained_notice(caps, app.theme.glyphs.dash); app.say(Tone::Muted, notice); @@ -1449,6 +1451,22 @@ async fn loop_over Result>( "nothing is running — /steer sends what is queued into a turn that is already \ working, and it is read at that turn's next step", ), + // Reached at an idle prompt, where `/compact` is not a refusal: + // there is no turn to steer, so the request rides the NEXT turn's + // contract as `TaskContract::fold_now` and folds at that turn's + // first step. While a turn runs, the driver's own key handler + // answers the word before `parse` is called, the way `/steer` is. + Action::Compact => { + let dash = app.theme.glyphs.dash; + // Read off `opening` — built at startup from the same builder + // every turn uses — rather than from a fifth + // `contract::session` call, which `tests/contract.rs` counts + // and fails at five. Nothing in `[run]` or `[app.io-cli]` can + // move `compaction` between two turns of one session. + let said = io_cli::compact::Said::asked(opening.compaction, false); + fold_next = said == io_cli::compact::Said::Armed; + app.say(Tone::Muted, said.line(dash)); + } Action::Context => { // `reading` for the same reason the arm above binds it that // way — `tests/plan.rs` reads the plan-gate argument off a @@ -1599,6 +1617,9 @@ async fn loop_over Result>( contained.then_some(containment.as_ref()).flatten(), &capabilities, planning, + // Taken rather than read: one request, one turn. A queue + // of three prompts must not fold three times. + std::mem::take(&mut fold_next), text, // **This turn's own clock, not the session's.** What a // reader wants of the row above the prompt is how long the @@ -1786,6 +1807,12 @@ async fn turn( // and nothing else — a caps configuration decided it through 0.11.0, which // is how every contained turn ended up stopping for one. planning: bool, + // Whether this turn folds its history at its first step — the `/compact` an + // operator typed at the idle prompt before it. io-harness reads `fold_now` + // once, before the first step assembles its first request, and consumes it + // with `mem::take`, so a contract reused for every turn would not fold every + // turn; io-cli builds a fresh one anyway. + fold: bool, text: String, started: Instant, ) -> Result { @@ -1878,6 +1905,22 @@ async fn turn( std::sync::Arc::new(answerer), planning.then(|| std::sync::Arc::new(gate) as std::sync::Arc), ); + // **The idle half of `/compact`.** Set here rather than as a parameter of the + // builder: `contract::session` has three other callers that build a contract + // nothing runs — the startup reading and the two reporting pages — and a + // seventh parameter would be three `false`s of pure noise plus a signature + // break, against one call on the single contract that is actually a turn. + // `fold_now: false` is `TaskContract::workspace`'s own default, so the + // field-for-field gate is unmoved either way. + let contract = if fold { + contract.with_fold_now(true) + } else { + contract + }; + // Set while a fold has been asked for and no `Compacted` event has arrived. + // A one-shot: io-harness spends the request whether or not it folds, so what + // this guards is the report and never a retry. + let mut folding = fold; // **Read off the contract that is about to run, and never recomposed from the // configuration.** The ceilings on the line have to be the ceilings in force, // and the only thing that knows the whole order of precedence — the floor, @@ -1937,6 +1980,7 @@ async fn turn( app.event(&event, at); commit_edits(app, store, &event, screen.width()); commit_viewed(screen, app, &root, policy, &event)?; + commit_fold(app, store, &event, &mut folding); paint(screen, app)?; } Some(ask) = asks.recv() => { @@ -2071,6 +2115,37 @@ async fn turn( // sentence says exactly that. What *is* certain is // the negative, and the drain after this loop is // where it gets said. + // **`/compact` mid-turn.** `Steer::fold` lands at the + // turn's next step boundary — the same promise + // `/steer` makes, for the same reason: a tool call in + // flight is not a safe place to change the + // conversation out from under the model. + // + // **Asked, never reported folded.** io-harness names + // four conditions under which an accepted request + // folds nothing, and the request is spent under all + // four, so the only thing that may say a fold happened + // is `EventKind::Compacted`. + Command::Slash(ref line) if line.trim() == "compact" => { + let dash = app.theme.glyphs.dash; + let said = io_cli::compact::Said::asked(contract.compaction, true); + if said == io_cli::compact::Said::Sent { + match steer.fold() { + Ok(()) => { + folding = true; + app.say(Tone::Muted, said.line(dash)); + } + // Unreachable while `inbox` is alive, and + // said rather than swallowed anyway. + Err(error) => app.say( + Tone::Warning, + format!("nothing is listening {dash} {error}"), + ), + } + } else { + app.say(Tone::Muted, said.line(dash)); + } + } Command::Slash(ref line) if line.trim() == "steer" => { let dash = app.theme.glyphs.dash; let mut sent = 0usize; @@ -2228,6 +2303,7 @@ async fn turn( // on the turn's last step is exactly the one the drain would otherwise // lose. commit_viewed(screen, app, &root, policy, &event)?; + commit_fold(app, store, &event, &mut folding); } app.finished(); @@ -2253,6 +2329,23 @@ async fn turn( ); } + // **The fold that was asked for and never announced.** A conversation shorter + // than `Compaction::keep_recent` has no prefix a paragraph could stand in for, + // and an interrupt at the same boundary wins — io-harness reports neither, + // because there is nothing to report. The request is spent under both, so this + // is the end of the story rather than a retry, and it says nothing folded + // rather than that a fold happened. + // + // `record` and not `say`, for the reason the drain above it gives: a footer + // notice is gone at the next keystroke and would fight with the stop line. + if folding { + let dash = app.theme.glyphs.dash; + app.record( + Tone::Muted, + io_cli::compact::Said::unfolded(contract.compaction, stopped).line(dash), + ); + } + match outcome { // The operator's sentence in front of the harness's own line — see // `io_cli::failure`. A provider that will not take an image says so in the @@ -2289,6 +2382,25 @@ async fn turn( /// A read that fails degrades to a line saying so. A run whose work succeeded is /// not a run to panic over because the trace could not be re-read, and silence /// would say the step changed nothing. +/// Report a fold that was asked for — once, and only from the event that +/// announces it. +/// +/// io-harness emits `Compacted` the moment a fold lands and nothing at all when +/// one does not, so this is the whole of what may say a fold happened. It runs on +/// both event paths because the last step of a turn is exactly the one whose +/// event the select loop loses to the turn's own return. +fn commit_fold(app: &mut App, store: &Store, event: &io_harness::RunEvent, folding: &mut bool) { + if !*folding { + return; + } + let Some(said) = io_cli::compact::Said::folded(store, event) else { + return; + }; + *folding = false; + let dash = app.theme.glyphs.dash; + app.say(Tone::Muted, said.line(dash)); +} + fn commit_edits(app: &mut App, store: &Store, event: &io_harness::RunEvent, width: u16) { let io_harness::EventKind::Step { changed: true, .. } = &event.kind else { return; diff --git a/src/queue.rs b/src/queue.rs index 50593ba..de476ab 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -1,10 +1,23 @@ //! The queue surface: what is waiting behind the turn that is running. //! -//! **A renderer over [`crate::app::App`]'s waiting prompts, and nothing else.** -//! It owns no state, decides nothing about when a queued line runs, and cannot -//! reorder one. `App` holds the queue because the driver drains it; this draws -//! it, and the separation is what keeps "what is waiting" a single fact with a -//! single owner. +//! **A renderer over [`crate::app::App`]'s waiting prompts, plus the one piece +//! of state a renderer cannot borrow: where the operator is inside them.** +//! `App` still owns the queue itself — the driver drains it, and "what is +//! waiting" stays a single fact with a single owner. What lives here is +//! [`Cursor`]: which line is marked and which line has been taken into the +//! composer to be edited. That split is deliberate and it is the same one +//! [`crate::picker::Picker`] makes. A selection is not a fact about the +//! session, it is a fact about the *surface* — it is meaningless with the +//! surface shut, it must not survive into a session that has no queue, and +//! nothing outside the drawing of these rows has any business reading it. Put +//! on `App` it would be a fifth field about a list that already has three, and +//! every mutation of the queue would have to remember to keep it honest; +//! held here, the verbs that move the queue and the arithmetic that draws it +//! are the same few lines and cannot disagree. +//! +//! The verbs take `&mut Vec` rather than owning it, which is the whole +//! of how both halves stay true at once: the queue is still `App`'s, and the +//! only code that reorders it is the code that draws it. //! //! Three decisions shape everything here, and each of them is a *refusal*. //! @@ -12,11 +25,24 @@ //! viewport because a run is stopped inside a harness callback waiting for the //! answer — see [`crate::app::App::modal`], which is the one predicate naming //! all three. Nothing is blocked by a queue: the turn goes on streaming, the -//! operator goes on typing, and the only thing this surface knows is a list. So -//! it takes no keyboard, sets no cursor, and the session it is drawn over -//! behaves exactly as it did before it appeared. Adding it to `modal()` would -//! make `Ctrl+C` reach a guard rather than the turn, which is a surface that -//! swallows the interrupt while claiming to be a list. +//! operator goes on typing, and the only thing this surface knows is a list. It +//! sets no cursor, and the session it is drawn over behaves exactly as it did +//! before it appeared. Adding it to `modal()` would make `Ctrl+C` reach a guard +//! rather than the turn, which is a surface that swallows the interrupt while +//! claiming to be a list. +//! +//! It does take four keys, and only while it is up — the arrows, the shifted +//! arrows, `Enter` on an empty prompt and `Esc` — which is what F3 asks for and +//! is not the same thing as taking the keyboard. Every other key still falls +//! through to the composer, because typing is how the *next* line joins the +//! queue this is drawing. The cost is named where it is paid: while the surface +//! is open `Up` marks a row instead of walking back through prompt history, the +//! same trade the fleet view already makes with the same two keys. `Esc` shuts +//! the surface and hands both back. Binding those keys at the *composer* rather +//! than inside the open surface is the one mistake this design has to refuse — +//! it would cost an operator with nothing queued the history recall that has +//! been documented since 0.1.0, in exchange for moving a selection through a +//! list that is not on screen. //! //! **It never grows the viewport.** The rows it draws come out of the //! composer's own allowance, the way [`crate::fleet::Fleet::render`] takes them, @@ -50,8 +76,218 @@ use crate::glyphs::Glyphs; use crate::picker::fit; use crate::theme::{Theme, Tone}; +/// Where the operator is inside the queue, and which line they are editing. +/// +/// **Two `Option`s and no third field, because every other question is already +/// answered by the queue itself.** How long the list is, what is on it and +/// whether it is on screen at all are `App`'s — see +/// [`crate::app::App::queue_open`], which is three facts and no fourth field for +/// the same reason. This holds only what the queue cannot tell you: a mark, and +/// a line that is out of the queue and in the prompt. +/// +/// **The mark is stored as an index and read through +/// [`Cursor::selection`], which clamps.** The queue mutates underneath it — +/// the driver takes the oldest off the front between turns, and the operator can +/// drop a line from the middle — so an index held here can outlive the line it +/// pointed at. 0.7.0 paid for the other answer in the picker: an index stored +/// into a list that is *recomputed* moves the marked row every time the list +/// changes shape. Here the list is not derived from anything, so the index is +/// the honest handle; what it needs is a reader that never indexes past the end, +/// and every read in this module goes through that one. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Cursor { + /// The marked line, as an index into the queue. `None` until an arrow says + /// otherwise, and that is what keeps a surface nobody has touched drawing + /// exactly the rows it drew before this type existed. + at: Option, + /// The line taken into the composer, as the position it came from and the + /// text it had when it left. + /// + /// **Both halves, because the second is the undo.** The composer holds what + /// the operator is typing *now*; this holds what they would get back by + /// pressing `Esc`, which is 0.13.1's rule — an erase is undoable where the + /// undo is cheap, and one `String` for the length of one edit is as cheap as + /// undo gets. + editing: Option<(usize, String)>, +} + +/// What putting an edited line back did. +/// +/// Returned rather than said here: the sentence belongs to the footer, which is +/// `App`'s, and this module draws rows. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Put { + /// It went back, at this position. + Kept(usize), + /// There was nothing left of it to put back, so the line is gone. Carries + /// the text it had, so the caller can name what it dropped. + Dropped(String), +} + +impl Cursor { + /// The marked line, given how long the queue is now. + /// + /// The only way to read the mark. See the field's own note for why. + pub fn selection(&self, waiting: usize) -> Option { + let at = self.at?; + (waiting > 0).then(|| at.min(waiting - 1)) + } + + /// Move the mark by `delta`, and say whether the key was ours. + /// + /// **`false` is not a failure, it is the key falling through to the + /// composer**, and it is what keeps `Down` from meaning two things. With + /// nothing marked, `Up` enters the list at the line nearest the prompt — the + /// row physically above the composer the operator's caret is in, which is + /// where an upward key lands on any other list in any other program. `Down` + /// with nothing marked enters nothing: below the queue is the composer, the + /// operator is already there, and the key goes on doing what it does at a + /// prompt with a queue behind it. + pub fn move_by(&mut self, delta: isize, waiting: usize) -> bool { + if waiting == 0 { + return false; + } + let last = waiting - 1; + let at = match self.selection(waiting) { + Some(at) => at as isize + delta, + None if delta < 0 => last as isize, + None => return false, + }; + // Clamped rather than wrapped. A list of things that are about to run + // has a first and a last, and an arrow that jumped from one end to the + // other would move a line the operator was not looking at. + self.at = Some(at.clamp(0, last as isize) as usize); + true + } + + /// Move the marked **line** by `delta`, carrying the mark with it. + /// + /// The mark follows the text rather than staying on the slot, which is the + /// whole difference between reordering a queue and scrolling through one: + /// three presses move a line three places, and the operator watches the same + /// line travel. A mark that stayed put would move a different line on every + /// press. + /// + /// Refused at either end rather than wrapped, for [`Cursor::move_by`]'s + /// reason, and refused with nothing marked because there is no line to move. + pub fn reorder(&mut self, delta: isize, waiting: &mut [String]) -> bool { + let Some(at) = self.selection(waiting.len()) else { + return false; + }; + let to = at as isize + delta; + if to < 0 || to >= waiting.len() as isize { + return false; + } + let to = to as usize; + waiting.swap(at, to); + self.at = Some(to); + true + } + + /// The position of the line currently out of the queue and in the composer. + pub fn editing(&self) -> Option { + self.editing.as_ref().map(|(at, _)| *at) + } + + /// Take the marked line **out** of the queue, to be edited in the composer. + /// + /// **Out, not copied, and that is the decision the whole edit hangs on.** A + /// line drawn in the queue while a second copy of it sits in the prompt is a + /// session that would run it twice if the turn ended in between — and the + /// turn *can* end in between, because nothing about an edit stops one. Out of + /// the queue there is exactly one copy of that prompt in the session and it + /// is in the most visible place a session has, so the answer to "what happens + /// to a line being edited when the turn ends underneath it" is: nothing + /// happens to it. The surface closes with the turn, the drain never sees the + /// line, and the operator is left holding it at an idle prompt where `Enter` + /// sends it as its own turn — which is the turn it was queued to become. + /// + /// `None` when nothing is marked or an edit is already in flight, so the + /// keystroke falls through to the composer rather than silently taking a + /// second line. + pub fn take(&mut self, waiting: &mut Vec) -> Option { + if self.editing.is_some() { + return None; + } + let at = self.selection(waiting.len())?; + let text = waiting.remove(at); + self.editing = Some((at, text.clone())); + Some(text) + } + + /// Put an edited line back where it came from. + /// + /// **At its own position, and the position is the point.** A queue is an + /// order; an edit that returned a line to the end of it would have quietly + /// reordered the session's next few turns as the price of fixing a typo, and + /// the operator has a key for reordering when they want one. + /// + /// Clamped to the length, because the queue can be shorter than it was: the + /// driver may have drained a line while the edit was open. Past the end is + /// the end, which is still the truthful answer to "after everything that was + /// in front of it". + /// + /// **Empty is a drop.** There is no other honest reading of a line an + /// operator has erased and pressed `Enter` on: an empty prompt is not a + /// prompt — [`crate::composer::Composer`] has refused to submit one since + /// 0.1.0 — and putting an empty string back into the queue would schedule a + /// turn with nothing in it. The text comes back with the answer so the caller + /// can say *what* it dropped rather than that something was dropped. + /// + /// `None` when no edit was in flight, which is the caller's signal that the + /// key was never ours. + pub fn put_back(&mut self, waiting: &mut Vec, text: &str) -> Option { + let (at, was) = self.editing.take()?; + if text.trim().is_empty() { + // The mark stays where the line was, so the next arrow starts from + // the hole rather than from the top of a list that just got shorter. + self.at = Some(at); + return Some(Put::Dropped(was)); + } + let at = at.min(waiting.len()); + waiting.insert(at, text.to_string()); + self.at = Some(at); + Some(Put::Kept(at)) + } + + /// Abandon the edit and put the line back exactly as it was taken. + /// + /// The undo half of [`Cursor::take`], and the reason the original text is + /// held: `Esc` on a half-edited line is "I did not mean to touch this", and + /// answering it with the half-edited text would be a cancel that still + /// changed the queue. + pub fn cancel(&mut self, waiting: &mut Vec) -> Option { + let (at, was) = self.editing.take()?; + let at = at.min(waiting.len()); + waiting.insert(at, was); + self.at = Some(at); + Some(at) + } + + /// Forget the mark and any edit, because the turn they were about has ended. + /// + /// **It does not put a lapsed edit back.** The line is in the composer, where + /// the operator can see it and send it; re-queueing it here would put a + /// second copy behind the drain that is about to start, and both of them + /// would run. What this clears is the *position*, which is the part that goes + /// stale — a slot remembered across a drain and a new turn's queue points at + /// somebody else's line. + pub fn lapsed(&mut self) { + *self = Self::default(); + } +} + +/// The rows this surface would draw for `waiting` with nothing marked. +/// +/// The surface as it is until an operator touches it, which is nearly always: +/// the queue opens itself, and most of the time it is read rather than driven. +/// [`rows_for`] is the same rows with a mark on one of them. +pub fn rows(waiting: &[String], width: u16, room: u16, glyphs: &Glyphs) -> Vec { + rows_for(waiting, None, width, room, glyphs) +} + /// The rows this surface would draw for `waiting`, oldest first, at most `room` -/// of them and none wider than `width`. +/// of them and none wider than `width`, with `selected` marked. /// /// **The oldest are the ones kept.** They are the ones that run next, so a /// window that followed the newest would show the operator the part of the queue @@ -61,12 +297,44 @@ use crate::theme::{Theme, Tone}; /// [`Glyphs::elision`] exists for exactly this — *lines are missing here*, /// always followed by how many. /// +/// **A mark moves the window, and it has to.** A selection the operator cannot +/// see is worse than no selection at all — it is a list where the arrows appear +/// to do nothing and then something moves. So the rows scroll to keep the marked +/// line on screen, exactly as [`crate::fleet::Fleet::render`] does, and the +/// numbers stay absolute while they scroll: the number is the run order, and a +/// window that renumbered itself from one would be telling the operator that the +/// line they marked runs first. +/// +/// The marker column costs two cells on *every* row, or none on any of them. +/// [`Glyphs::marker`] is two cells wide in both sets precisely so it can be +/// swapped for two spaces without moving the text beside it; a column drawn only +/// on the marked row would shift that one row out of line with its neighbours, +/// which reads as the row having changed rather than as it having been chosen. +/// With nothing marked there is no column at all, so a surface nobody has +/// touched is drawn to the same width it always was. +/// /// Separate from [`render`] so the arithmetic can be asserted without a frame. -pub fn rows(waiting: &[String], width: u16, room: u16, glyphs: &Glyphs) -> Vec { +pub fn rows_for( + waiting: &[String], + selected: Option, + width: u16, + room: u16, + glyphs: &Glyphs, +) -> Vec { let room = usize::from(room); if room == 0 || waiting.is_empty() { return Vec::new(); } + // Clamped here as well as in [`Cursor::selection`], because this is a public + // function over a slice and the index is used to *subscript* it below. One + // caller reading a mark that outlived its line would be a panic in a + // renderer, which is a crash in the middle of somebody's turn. + let selected = selected.map(|at| at.min(waiting.len() - 1)); + let mark = |at: usize| match selected { + Some(on) if on == at => glyphs.marker, + Some(_) => " ", + None => "", + }; // **One row is the case that matters, because one row is what the running // viewport actually has.** Eight rows, of which the streaming tail, the // activity line, the rule and the three-row footer take six and the composer @@ -77,11 +345,23 @@ pub fn rows(waiting: &[String], width: u16, room: u16, glyphs: &Glyphs) -> Vec 1 { + // **The one row goes to the line the operator is working on, and with + // nothing marked that is the line that runs next.** This is where a + // selection at one row either reads or does not exist: an arrow that + // moved a mark onto a line the single row never shows is an arrow that + // did nothing an operator could see. So the row follows the mark, the + // number on it says which line of the queue this is, and the count on + // the end goes on saying how many others are waiting — which is the same + // sentence it always said, because it was never a count of what is + // *below* the row, only of what is not on it. + let at = selected.unwrap_or(0); let rest = waiting.len() - 1; return vec![fit( &format!( - "1. {} {} {rest} more", - one_line(&waiting[0]), + "{}{}. {} {} {rest} more", + mark(at), + at + 1, + one_line(&waiting[at]), glyphs.elision ), width, @@ -95,21 +375,41 @@ pub fn rows(waiting: &[String], width: u16, room: u16, glyphs: &Glyphs) -> Vec shown => { + at.saturating_sub(shown - 1).min(waiting.len() - shown) + } + _ => 0, + }; let mut out: Vec = waiting .iter() - .take(shown) .enumerate() + .skip(first) + .take(shown) // Numbered rather than headed. A header row would spend the surface's // only row on a terminal that can spare one, and would repeat a sentence // the footer notice has already said; the position says both what the // rows are and which order they run in, on every row, for no rows at // all. - .map(|(at, prompt)| fit(&format!("{}. {}", at + 1, one_line(prompt)), width, glyphs)) + .map(|(at, prompt)| { + fit( + &format!("{}{}. {}", mark(at), at + 1, one_line(prompt)), + width, + glyphs, + ) + }) .collect(); let hidden = waiting.len() - shown; if hidden > 0 { out.push(fit( - &format!("{} {hidden} more", glyphs.elision), + &format!( + "{}{} {hidden} more", + if selected.is_some() { " " } else { "" }, + glyphs.elision + ), width, glyphs, )); @@ -133,18 +433,41 @@ fn one_line(prompt: &str) -> String { /// /// Muted, because none of it has run: the composer under it and the turn above /// it are what is live, and a queue drawn in the colour of either would compete -/// with them for an attention it has no claim on. +/// with them for an attention it has no claim on. The marked row is the one +/// exception, in [`Tone::Accent`] — the tone the theme names for a selection — +/// because a mark that is only a glyph is a mark an operator has to hunt for on +/// a row that is the same colour as every other row. /// -/// **No cursor.** [`crate::fleet::Fleet::render`] parks one on its marked row -/// because the arrows move that marker and a reader has to be able to find it; -/// nothing here is selectable, and the composer below owns the cursor. A second -/// widget setting it would move the terminal's own cursor off the prompt an -/// operator is typing into. -pub fn render(waiting: &[String], frame: &mut Frame, area: Rect, theme: &Theme) { - let lines: Vec> = rows(waiting, area.width, area.height, &theme.glyphs) - .into_iter() - .map(|row| Line::from(Span::styled(row, theme.style(Tone::Muted)))) - .collect(); +/// **No cursor, and this is where it parts company with the fleet view.** +/// [`crate::fleet::Fleet::render`] parks the terminal's cursor on its marked row, +/// and can, because it is drawn *over* the composer and there is no prompt +/// underneath to take the caret away from. This surface draws above a composer +/// that is still visible and still taking typing — that is the refusal at the top +/// of this module — so the caret stays in the prompt where the next line is being +/// written, and the mark is carried by the glyph and the tone instead. +pub fn render( + waiting: &[String], + selected: Option, + frame: &mut Frame, + area: Rect, + theme: &Theme, +) { + let lines: Vec> = + rows_for(waiting, selected, area.width, area.height, &theme.glyphs) + .into_iter() + // Read back off the row rather than recomputed from `selected`: the + // window that decides *which* rows these are lives in `rows_for`, and a + // second copy of that arithmetic here is the copy that would drift and + // paint the wrong row. + .map(|row| { + let tone = if row.starts_with(theme.glyphs.marker) { + Tone::Accent + } else { + Tone::Muted + }; + Line::from(Span::styled(row, theme.style(tone))) + }) + .collect(); if lines.is_empty() { return; } diff --git a/tests/commands.rs b/tests/commands.rs index 318e4b4..035453b 100644 --- a/tests/commands.rs +++ b/tests/commands.rs @@ -48,6 +48,9 @@ fn the_commands_are_the_commands() { // 0.17.0 — a word rather than a default, because a delivered steer // emits no event this interface can draw. "/steer", + // 0.17.0 — the other word said *to* a turn, and the only other + // command whose effect is decided by whether one is running. + "/compact", "/copy", "/copy diff", "/config", diff --git a/tests/compact.rs b/tests/compact.rs new file mode 100644 index 0000000..09f55b1 --- /dev/null +++ b/tests/compact.rs @@ -0,0 +1,332 @@ +//! F8 — `/compact` folds, and reports the fold rather than the request. +//! +//! **What this file can and cannot prove, first, because the gap is the shape of +//! the feature.** +//! +//! io-harness documents four conditions under which a fold request that was +//! accepted folds nothing — it is not immediate, it does not override an off +//! setting, it loses to an interrupt at the same boundary, and it does nothing +//! when there is nothing to fold — and it says what an interface may do about +//! them: it "must not report a fold on the strength of having sent one — read the +//! `Compacted` event instead." +//! +//! So `Steer::fold()` returning `Ok` proves nothing, exactly as `Steer::say` +//! returning `Ok` proves nothing for `/steer`. What is provable here is that +//! io-cli never mistakes the one for the other: +//! +//! 1. **A fold is claimed only from the event.** `Said::Folded` has one +//! constructor and it takes a `RunEvent`; every other arm — sent, armed, off, +//! and the turn that ended with no event — answers `is_fold()` with `false`. +//! 2. **The short conversation reports nothing to fold.** This is the arm the +//! sabotage lands on, and the test named below is the one that kills it. +//! 3. **Folding off is decided from the configuration, before anything is sent.** +//! The only condition of the four that can be predicted, and the reason +//! `Said::asked` takes a `Compaction` at all. +//! 4. **The summary names what it replaced**, out of the run's own `summaries` +//! row — a count the `Compacted` event does not carry and the store does. +//! +//! **Not provable without a network, and owed to the live run:** that a real turn +//! folds when asked. That evidence is one `EventKind::Compacted` in a real run's +//! trace with a `summaries` row beside it, and it comes from io-harness rather +//! than from anything asserted here. + +use io_cli::commands::{self, Action, COMMANDS}; +use io_cli::compact::Said; +use io_cli::keys::Keys; +use io_cli::theme::DARK; +use io_harness::{Compaction, EventKind, RunEvent, Store}; + +/// The separator the sentences are formatted with. Any of them would do — the +/// assertions below are about the words, not the glyph. +fn dash() -> &'static str { + DARK.glyphs.dash +} + +/// Folding on, at io-harness's own defaults: `at_share` 0.8, `keep_recent` 8. +fn folding() -> Compaction { + Compaction::default() +} + +/// Folding off, the way io-harness documents off: a threshold the ledger cannot +/// reach, which is a setting rather than an absence. +fn never() -> Compaction { + Compaction { + at_share: 1.0, + ..Compaction::default() + } +} + +fn compacted(run_id: i64, through_step: u32, before: u64, after: u64) -> RunEvent { + RunEvent::new( + run_id, + through_step, + EventKind::Compacted { + through_step, + before_tokens: before, + after_tokens: after, + }, + ) +} + +fn store() -> Store { + Store::memory().expect("an in-memory store") +} + +/// `src/main.rs` as text. Nothing under `tests/` links the binary, so a decision +/// made in the driver is one no test can drive; this reads the driver instead. +fn driver() -> String { + std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/main.rs")) + .expect("the driver") +} + +// --- F8: a fold is claimed only from the event ------------------------------- + +/// **The asymmetry that is the whole feature.** A request can be built out of a +/// setting and a boolean; a fold cannot be built out of anything but an event. +#[test] +fn f8_only_the_event_makes_a_fold() { + assert!( + !Said::asked(folding(), true).is_fold(), + "a request sent into a running turn is a request", + ); + assert!( + !Said::asked(folding(), false).is_fold(), + "a request armed on the next turn's contract is a request", + ); + assert!(!Said::asked(never(), true).is_fold()); + assert!(!Said::unfolded(folding(), false).is_fold()); + assert!(!Said::unfolded(folding(), true).is_fold()); + + // And the constructor that can say so refuses every other kind of event: a + // fold is announced by one variant and nothing else in the stream stands in + // for it. + let store = store(); + let run_id = store.start_run("fold it", "openrouter").expect("a run"); + assert_eq!( + Said::folded( + &store, + &RunEvent::new( + run_id, + 3, + EventKind::CacheMarked { + through_step: 3, + prefix_bytes: 8_412, + }, + ) + ), + None, + "only `Compacted` says a fold happened", + ); + + let said = Said::folded(&store, &compacted(run_id, 12, 19_400, 5_100)).expect("the fold"); + assert!(said.is_fold()); + let line = said.line(dash()); + assert!(line.contains("folded"), "{line}"); + // The two token figures are the point of the event — a fold that bought + // nothing must be visible as such, so both ends are on the line. + assert!(line.contains("19.4k"), "{line}"); + assert!(line.contains("5.1k"), "{line}"); +} + +// --- F8: the short conversation ------------------------------------------------ + +/// **The test that kills the sabotage.** +/// +/// Sabotage: report a fold as soon as the request is accepted — `Said::asked` +/// answering `Folded` instead of `Sent`, or its sentence borrowing the past +/// tense. On every long conversation that is indistinguishable from working code, +/// because the event arrives a step later and says the same thing. On a +/// conversation shorter than `Compaction::keep_recent` there is no prefix a +/// paragraph could stand in for, io-harness folds nothing, no `Compacted` event +/// is ever emitted — and the sabotaged interface has announced a fold that the +/// harness's own event never claimed. +/// +/// So the arm is asserted end to end: the request is accepted, nothing is +/// observed, and what the operator reads says there was nothing to fold and that +/// the request is gone. **The request is spent either way**, which is why the +/// sentence has to say so — an operator who thinks it is still queued waits for a +/// fold that is never coming. +#[test] +fn f8_a_short_conversation_reports_nothing_to_fold() { + let accepted = Said::asked(folding(), true); + assert_eq!(accepted, Said::Sent); + assert!( + !accepted.is_fold(), + "accepting a request is not observing a fold", + ); + let asked = accepted.line(dash()); + assert!( + !asked.contains("folded"), + "the request's own line claims a fold: {asked}", + ); + + // The turn ran to its end and the event never came. + let said = Said::unfolded(folding(), false); + assert!(!said.is_fold()); + let line = said.line(dash()); + assert!(line.contains("nothing to fold"), "{line}"); + assert!(!line.contains("folded"), "{line}"); + assert!( + line.contains("spent"), + "the operator is not told the request is gone: {line}", + ); + // The number a fold keeps whole, so the sentence says why nothing folded + // rather than only that nothing did. + assert!(line.contains('8'), "{line}"); +} + +/// The other condition nothing can predict: a fold that lost to the interrupt +/// sent before the same step boundary. Reported as what it is, and not as a fold. +#[test] +fn f8_an_interrupted_turn_says_the_request_went_with_it() { + let said = Said::unfolded(folding(), true); + assert!(!said.is_fold()); + let line = said.line(dash()); + assert!(line.contains("stopped"), "{line}"); + assert!(!line.contains("folded"), "{line}"); +} + +// --- F8: folding off ----------------------------------------------------------- + +/// **The one condition of the four that is known in advance**, and the only one +/// under which nothing is sent at all. +/// +/// `Compaction { at_share: 1.0, .. }` never folds and no trigger reverses that — +/// off is a setting rather than an absence — so a request made under it would be +/// spent for nothing and reported as pending forever. Predicted from the +/// contract's own setting, before anything leaves the process, on both arms: +/// whether a turn is running does not change what an off setting does. +#[test] +fn f8_folding_off_is_said_before_anything_is_sent() { + assert_eq!(Said::asked(never(), true), Said::Off); + assert_eq!(Said::asked(never(), false), Said::Off); + + let line = Said::Off.line(dash()); + assert!(line.contains("off"), "{line}"); + assert!( + line.contains("nothing was sent"), + "the operator is not told the request never left: {line}", + ); + assert!(!line.contains("folded"), "{line}"); + + // A threshold a config file could produce by accident rather than on purpose + // is off too, and is answered the same way: io-harness reads a non-finite or + // negative share as never folding, and an interface that read it as "fold + // always" would send a request into a run that cannot honour it. + assert_eq!( + Said::asked( + Compaction { + at_share: f32::NAN, + ..Compaction::default() + }, + true + ), + Said::Off, + ); +} + +// --- F8: what the summary replaced --------------------------------------------- + +/// The event carries a step and two token figures and no text; the run's own +/// `summaries` row carries how many observations from the front of the ledger the +/// paragraph stands in for. The report is both, because "folded" on its own does +/// not tell an operator what left the model's window. +/// +/// **And nothing was destroyed to produce it.** A fold deletes nothing from the +/// store — the observations the row counts are still readable — which is what +/// makes naming them safe rather than a report of a loss. +#[test] +fn f8_the_summary_names_what_it_replaced() { + let store = store(); + let run_id = store + .start_run("port the parser", "openrouter") + .expect("a run"); + store + .put_summary(run_id, 12, 40, "Read the lexer, kept the token enum.", 11) + .expect("a summary"); + + let said = Said::folded(&store, &compacted(run_id, 12, 19_400, 5_100)).expect("the fold"); + assert_eq!( + said, + Said::Folded { + through_step: 12, + before: 19_400, + after: 5_100, + replaced: Some(40), + }, + ); + let line = said.line(dash()); + assert!(line.contains("40 observations"), "{line}"); + assert!(line.contains("12"), "{line}"); + + // A fold whose row is not readable is still a fold: the event is the fact and + // the row is the detail, so what is lost is one number rather than the report. + let other = Said::folded(&store, &compacted(run_id, 30, 21_000, 6_000)).expect("the fold"); + assert_eq!( + other, + Said::Folded { + through_step: 30, + before: 21_000, + after: 6_000, + replaced: None, + }, + ); + assert!(other.line(dash()).contains("folded")); +} + +// --- F8: the command ----------------------------------------------------------- + +/// It is listed, it is grouped with the other words said to a turn, and it +/// resolves to an action of its own rather than to the unknown-command print. +#[test] +fn f8_compact_is_a_command() { + assert!( + COMMANDS.iter().any(|(name, _)| *name == "/compact"), + "a command nobody can find in `/help` or the palette", + ); + assert_eq!( + commands::group_of("/compact"), + Some(commands::Group::Turn), + "it changes what this turn does, so it belongs with `/steer`", + ); + assert_eq!( + commands::parse("compact", &Keys::default(), &DARK), + Action::Compact, + ); + // One spelling. `/fold` is io-harness's own word and nobody has typed it at a + // prompt, and an alias that worked at an idle prompt while the driver's + // mid-turn arm matched only `compact` would be worse than no alias at all. + assert!(matches!( + commands::parse("fold", &Keys::default(), &DARK), + Action::Print(_) + )); +} + +// --- F8: the driver ------------------------------------------------------------ + +/// **Both triggers are actually wired, which nothing else here can see.** +/// +/// Nothing under `tests/` links the binary, so the driver's decisions are ones no +/// test can drive — this file reads `src/main.rs` instead, the instrument +/// `tests/steer.rs`, `tests/contract.rs` and `tests/structure.rs` already use for +/// exactly that reason. +/// +/// Two calls and they are not interchangeable: `Steer::fold` is read at a running +/// turn's next step boundary, and `TaskContract::fold_now` at a turn's first step +/// before it assembles its first request. An implementation with only the first +/// leaves `/compact` doing nothing at an idle prompt, which is where an operator +/// who has just finished a long thread actually types it. +#[test] +fn f8_the_driver_reaches_both_triggers() { + let squashed: String = driver().chars().filter(|c| !c.is_whitespace()).collect(); + + assert!( + squashed.contains("steer.fold()"), + "the mid-turn request is never sent, so `/compact` waits for a turn that has already \ + started", + ); + assert!( + squashed.contains("with_fold_now"), + "the idle request never reaches a contract, so `/compact` at a prompt does nothing", + ); +} diff --git a/tests/dependencies.rs b/tests/dependencies.rs index ee35e40..8a03fc3 100644 --- a/tests/dependencies.rs +++ b/tests/dependencies.rs @@ -80,13 +80,86 @@ fn manifest() -> toml::Value { toml::from_str(&text).expect("the manifest parses") } +fn names_in(table: Option<&toml::Value>) -> BTreeSet { + table + .and_then(toml::Value::as_table) + .map(|table| table.keys().cloned().collect()) + .unwrap_or_default() +} + +/// Every crate this manifest asks for by name — which is not the same set as +/// `[dependencies]`, and 0.17.0 is the release that stopped pretending it was. +/// +/// N1 is about what `cargo tree --depth 1` prints, and `--depth 1` prints more +/// tables than one. A `[build-dependencies]` entry is a crate compiled and *run* +/// on the build machine. A `[target.'cfg(unix)'.dependencies] nix` is a direct +/// dependency on three of the four release artifacts and invisible on the fourth +/// — and `nix` is in `FORBIDDEN` below precisely because it is the shape a +/// sandbox arrives in, which is also the shape that arrives `cfg`-guarded. Before +/// this release either one could have been added and no gate in this file would +/// have said a word. +/// +/// So they are folded in here rather than given a test of their own: every +/// assertion built on this function — the ALLOWED set in both directions, and the +/// forbidden-subsystem sweep — now covers them for free, and a dependency is a +/// dependency whichever `cfg` happens to guard it. fn direct_dependencies() -> BTreeSet { let manifest = manifest(); - let table = manifest - .get("dependencies") - .and_then(toml::Value::as_table) - .expect("a dependencies table"); - table.keys().cloned().collect() + let mut names = names_in(manifest.get("dependencies")); + assert!( + !names.is_empty(), + "a dependencies table — an empty read here would pass every assertion below \ + it for the wrong reason", + ); + names.extend(names_in(manifest.get("build-dependencies"))); + + // `[target..dependencies]`, for every `` the manifest names. The + // cfg keys are not enumerated: a gate that listed the platforms it knew about + // would stop covering the platform somebody adds. + if let Some(targets) = manifest.get("target").and_then(toml::Value::as_table) { + for spec in targets.values() { + names.extend(names_in(spec.get("dependencies"))); + names.extend(names_in(spec.get("build-dependencies"))); + } + } + names +} + +/// N1 — the other half of `--depth 1`, which is deliberately not in `ALLOWED`. +/// +/// A dev-dependency never reaches an artifact, so putting `tempfile` on the list +/// the release record argues over would blur what that list means: `ALLOWED` is +/// what ships. But `cargo tree --depth 1` prints dev-dependencies, N1 says that +/// output names exactly what it names today, and a test-only crate is still a +/// crate somebody has to justify — `tempfile` is here because a run store needs a +/// directory that cleans itself up, and the next name needs an argument of its +/// own. Pinning the set costs the same red check as `ALLOWED` does and keeps the +/// two meanings apart. +#[test] +fn n1_the_only_test_only_crate_is_the_one_that_makes_a_temporary_directory() { + let manifest = manifest(); + let dev: Vec = names_in(manifest.get("dev-dependencies")) + .into_iter() + .collect(); + assert_eq!( + dev, + vec!["tempfile".to_string()], + "the dev-dependency set changed. `cargo tree --depth 1` prints these too, \ + so a name added here grows the crate by N1's own measure and is argued in \ + the release record like any other.", + ); + + // And no `[target..dev-dependencies]` either, which would be the same + // name arriving where the assertion above cannot see it. + if let Some(targets) = manifest.get("target").and_then(toml::Value::as_table) { + for (spec, table) in targets { + assert!( + names_in(table.get("dev-dependencies")).is_empty(), + "[target.{spec}.dev-dependencies] hides a test-only crate from the \ + assertion above", + ); + } + } } #[test] @@ -160,10 +233,23 @@ fn f7_no_source_file_loops_over_provider_responses() { .join("src") .join("provider.rs"); let forwarding = std::fs::read_to_string(&delegating).expect("the provider module"); - let forwards = forwarding.matches("self.inner.complete").count(); + // Whitespace squashed, because rustfmt decides where a long call breaks and a + // count that matched a contiguous string found two of three the first time + // this ran — the same reason `tests/contract.rs` squashes before it looks for + // the turn entry points. An assertion about where a newline sits is an + // assertion about formatting. + let squashed: String = forwarding.chars().filter(|c| !c.is_whitespace()).collect(); + let forwards = squashed.matches("self.inner.complete").count(); + // Three, and the third is the one that matters: `complete_streaming_calls` is + // what io-harness's own loop calls, and its TRAIT DEFAULT drops the tool-call + // sink and forwards to `complete_streaming`. A decorator that did not override + // it would compile, record the request, and silently take tool-call streaming + // away from every run it wrapped. assert!( forwards >= 3, - "the decorator forwards every completion method it implements; found {forwards}", + "the decorator forwards every completion method the trait declares — including \ + `complete_streaming_calls`, whose default would cost the run its tool-call \ + streaming; found {forwards}", ); // A forward hands the request on and returns what the inner provider // returned. If this module ever reads a response — a token, a tool call, a diff --git a/tests/interrupt.rs b/tests/interrupt.rs index 1112c2b..9c471b2 100644 --- a/tests/interrupt.rs +++ b/tests/interrupt.rs @@ -1,16 +1,28 @@ //! F6 — an interrupt stops the turn and keeps the session. //! //! Asserted at the seam rather than against a live provider: `Ctrl+C` during a -//! running turn must reach `Steer::interrupt`, the partial output must be -//! committed rather than lost with the turn, and the composer must take the next -//! prompt. A real interrupted turn against a real model is F1's job. +//! running turn must stop it, the partial output must be committed rather than +//! lost with the turn, and the composer must take the next prompt. A real +//! interrupted turn against a real model is F1's job. +//! +//! **The mechanism is half of F6 and it is not the steer inbox.** Since 0.17.0 +//! both turn arms hold a `SteerInbox`, so `Steer::interrupt` exists and would +//! reach the same `RunOutcome::Cancelled` at the same step boundary. The stop key +//! stays on the observer's flag — `Flow::Cancel` out of `io_cli::bridge` — and +//! that is asserted here rather than remembered, because it is a decision +//! `src/main.rs` makes and nothing under `tests/` links the binary. This sentence +//! used to read "must reach `Steer::interrupt`", which was the opposite of what +//! the driver has ever done. mod support; +use std::collections::BTreeMap; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use io_cli::app::{App, Command, Mode}; +use io_cli::keys::{Action, Chord, Hit, Keys}; use io_cli::theme::DARK; -use io_harness::{EventKind, RunEvent, Steer}; +use io_harness::{EventKind, Plan, PlanStep, Question, RunEvent, Steer}; fn key(code: KeyCode) -> KeyEvent { KeyEvent::new(code, KeyModifiers::NONE) @@ -60,6 +72,13 @@ fn f6_ctrl_c_during_a_turn_interrupts_it_and_keeps_the_partial_output() { // Ctrl+C. assert_eq!(app.key(control('c')), Command::Interrupt); + + // **Sent by hand, and that is the point.** `Command::Interrupt` is the whole + // of what the key produces; the driver turns it into the observer's flag, and + // `f6_the_stop_key_reaches_the_run_as_flow_cancel_and_not_the_steer_inbox` + // below is what asserts that. What this half shows is the other path — the one + // that exists, ends in the same outcome, and is deliberately not wired to any + // key: nothing but this test line can put an interrupt in the inbox. steer.interrupt().expect("the turn is still listening"); // io-harness 0.69.0 replaced the `(Vec, bool)` tuple with `Steering`, @@ -231,6 +250,394 @@ fn f9_a_streaming_answer_of_any_length_leaves_the_viewport_the_same_size() { assert!(tail.contains("the tail with no newline yet"), "{tail:?}"); } +/// A turn that has done a step's worth of work, so it is past [`App::undoable`] +/// and takes the ordinary two-press stop. +/// +/// `status.steps` rather than a streamed token, because what makes a turn +/// undoable is the step count and the rows on screen — see `App::undoable` — and +/// a test that streamed text to move one of them would be asserting the other by +/// accident. +fn working() -> App { + let mut app = App::new(DARK, "m"); + app.started(); + app.status.steps = Some(1); + assert!( + !app.undoable(), + "a turn with a step behind it is not undone" + ); + app +} + +/// **F6 — the first press on a turn that has done nothing takes it back whole.** +/// +/// The branch above the two-press stop, and the only one that answers with +/// `Command::Abandon` on the first key: no boundary to wait for and nothing +/// streamed to keep, so the driver puts the prompt back in the composer. +/// +/// Sabotage: drop the `undoable` arm from `App::interrupt_or_quit` — under which +/// this fails and the operator who pressed the key a moment after Enter waits for +/// a step boundary that has nothing in it. +#[test] +fn f6_the_first_press_takes_back_a_turn_that_has_done_nothing() { + let mut app = App::new(DARK, "m"); + type_text(&mut app, "sent by accident"); + assert_eq!( + app.key(key(KeyCode::Enter)), + Command::Submit("sent by accident".into()), + ); + app.started(); + + assert!( + app.undoable(), + "nothing has streamed and no step has finished" + ); + assert_eq!( + app.key(control('c')), + Command::Abandon, + "a turn with nothing in it is undone rather than stopped at a boundary", + ); +} + +/// **F6 — pressed again while it is stopping, it abandons; and `Esc` is the same +/// key.** +/// +/// Once asks, twice takes. The first press says where the turn will stop and +/// cancels through the observer, which io-harness honours at the next step +/// boundary; the second does not wait for one. `Esc` is a second interrupt while +/// a turn runs, so the two presses need not be the same chord — an operator who +/// read the sentence the first press printed presses the key it names. +/// +/// Sabotage: make the second press return `Command::Interrupt` again — under +/// which this fails, and it fails as a key that reads as ignored for as long as a +/// slow tool call takes. +#[test] +fn f6_a_second_press_while_stopping_abandons_and_esc_is_that_second_press() { + let mut app = working(); + assert_eq!(app.key(control('c')), Command::Interrupt); + let said = app + .status + .notice + .as_ref() + .map(|(_, text)| text.clone()) + .unwrap_or_default(); + assert!( + said.contains("stopping") && said.contains("again"), + "the first press says what is happening and what a second one does: {said:?}", + ); + assert_eq!( + app.key(control('c')), + Command::Abandon, + "the second press does not wait for a step boundary", + ); + + // And the same two presses spelled the other way, which is how the sentence + // above tells an operator to spell them. + let mut app = working(); + assert_eq!(app.key(key(KeyCode::Esc)), Command::Interrupt); + assert_eq!(app.key(key(KeyCode::Esc)), Command::Abandon); + + // Mixed, because neither key is a mode: the state that decides the second + // press is the turn's, not the chord's. + let mut app = working(); + assert_eq!(app.key(control('c')), Command::Interrupt); + assert_eq!(app.key(key(KeyCode::Esc)), Command::Abandon); +} + +/// **F6 — the stop key pre-empts an approval.** +/// +/// An open question owns the keyboard: the run is stopped waiting on an answer +/// and no other key reaches the composer. `Ctrl+C` is the exception, and it has +/// to be — an approval is exactly where an operator decides they want out, and a +/// surface that answered the stop key with "that is not one of the three answers" +/// would be the lock this product refuses to build. +/// +/// Sabotage: drop `.filter(|_| !interrupting)` from the approval guard in +/// `App::key` — under which this fails, and it fails with the key swallowed by an +/// overlay that returns `Command::None`. +#[tokio::test] +async fn f6_the_stop_key_pre_empts_an_approval() { + use io_harness::{Act, ApprovalContext, Approver, Request}; + + let (asker, mut asks) = io_cli::approval::channel(); + let deciding = tokio::spawn(async move { + let asker = asker; + asker + .decide_in_context( + &Request::new(Act::Write, "src/main.rs").with_content("fn main() {}\n"), + &ApprovalContext::new("tidy the parser"), + ) + .await + }); + let ask = asks + .recv() + .await + .expect("the question reached the interface"); + + let mut app = working(); + app.open_approval(ask); + assert!(app.modal(), "an approval owns the keyboard while it is up"); + + assert_eq!( + app.key(control('c')), + Command::Interrupt, + "the stop key interrupts rather than answering the question", + ); + + // The question is denied as a consequence of the turn ending, which is the + // driver's doing — dropping the overlay drops the `Ask`, and a dropped `Ask` + // is a denial by construction. Asserted here only so far as this file can: + // the key produced the interrupt and not an answer. + drop(app); + let decision = deciding.await.expect("the approver did not panic"); + assert!( + matches!(decision, io_harness::Decision::Deny { .. }), + "an abandoned question denies rather than approving on the way out: {decision:?}", + ); +} + +/// **F6 — the stop key pre-empts a question about intent.** +/// +/// Same terms as the approval, one surface down: every other key is the operator +/// typing prose, which is why an unanswered question must not be able to hold +/// the one key that ends the turn it belongs to. +/// +/// Sabotage: drop `.filter(|_| !interrupting)` from the intent guard — under +/// which this fails, and the operator is left typing an answer to a turn they +/// asked to stop. +#[test] +fn f6_the_stop_key_pre_empts_a_question_about_intent() { + let (answer, _reply) = tokio::sync::oneshot::channel(); + let mut app = working(); + app.open_intent(io_cli::intent::Asked { + question: Question::new("drop the column or keep it?"), + answer, + }); + assert!(app.asking(), "the overlay owns the keyboard while it is up"); + + assert_eq!( + app.key(control('c')), + Command::Interrupt, + "the stop key interrupts rather than becoming the first letter of an answer", + ); +} + +/// **F6 — the stop key pre-empts a plan gate.** +/// +/// The plan gate is the surface with the strongest claim on the keyboard: the +/// planning phase denies every write and every exec until somebody decides, so a +/// session waiting at it is a session that can do nothing else. That is precisely +/// why the stop key has to reach through it. +/// +/// Sabotage: drop `.filter(|_| !interrupting)` from the plan guard — under which +/// this fails, and the one surface that stops the whole agent becomes the one +/// that can also refuse to let go of it. +#[test] +fn f6_the_stop_key_pre_empts_a_plan_gate() { + let (verdict, _reply) = tokio::sync::oneshot::channel(); + let mut app = working(); + app.open_plan(io_cli::plan::Proposed { + plan: Plan::new([PlanStep::new("write the migration")]), + verdict, + }); + assert!(app.modal(), "a plan owns the keyboard while it is up"); + + assert_eq!( + app.key(control('c')), + Command::Interrupt, + "the stop key interrupts rather than approving, correcting or cancelling the plan", + ); +} + +/// **F6 — the queue surface does not take the stop key, on either press.** +/// +/// `tests/queue_surface.rs` asserts the first press reaches the turn and that the +/// surface is not modal. This is the half it does not cover: the surface stays up +/// through the stop — nothing about it is a turn-scoped acknowledgement — the +/// *second* press still abandons, and neither press is a queue command. A stop +/// that silently emptied the queue would be one key doing two jobs, which is the +/// thing 0.17.0 exists to avoid. +/// +/// Sabotage: give the queue surface the keyboard the way the fleet view has it, +/// or close it on `Command::Interrupt` — under which this fails on the press the +/// existing test does not make. +#[test] +fn f6_the_queue_surface_takes_neither_press() { + let mut app = working(); + app.queue_prompt("run the tests after this"); + assert!( + app.queue_open(), + "a prompt queued mid-turn opens the surface" + ); + + assert_eq!(app.key(control('c')), Command::Interrupt); + assert!( + app.queue_open(), + "the stop key is not the surface's dismissal — `Esc` is, and it is the key \ + the surface answers first", + ); + assert_eq!( + app.key(control('c')), + Command::Abandon, + "the second press reaches the turn with the surface still drawn over the composer", + ); + assert_eq!( + app.queued_prompts().len(), + 1, + "stopping a turn is not forgetting what was queued behind it; `/queue clear` is \ + the key that does that", + ); +} + +/// **F6 — the chord cannot be rebound in either spelling, and a file that tried +/// still has a working stop key.** +/// +/// Both spellings: naming `interrupt` in `[app.io-cli.keys]`, and putting some +/// *other* action on `ctrl+c`. `tests/keys.rs` asserts the refusal and that the +/// key still exits an idle prompt. What is asserted here is the half F6 is about +/// — that a session started from such a file can still stop a *running turn* — +/// because a refusal that left the binding half-applied would look identical at +/// an idle composer. +/// +/// Sabotage: honour the second spelling, `clear = "ctrl+c"`, on the grounds that +/// it does not name the immovable action — under which this fails on the arm the +/// first spelling never reaches. +#[test] +fn f6_the_stop_key_cannot_be_rebound_in_either_spelling() { + // The invariant `App::key` computes `interrupting` from, stated once: the + // default binding is the control chord and it fires rather than arming. + assert_eq!(Action::Interrupt.default_binding(), "ctrl+c"); + assert!(!Action::Interrupt.rebindable()); + assert_eq!( + Keys::default().hit(Chord::of(control('c')), None), + Some(Hit::Fire(Action::Interrupt)), + ); + + for asking in [("interrupt", "ctrl+x"), ("clear", "ctrl+c")] { + let configured: BTreeMap = + [(asking.0.to_string(), asking.1.to_string())].into(); + let (keys, notices) = Keys::resolve(Some(&configured)); + assert!( + notices.join("\n").contains("Ctrl+C is not rebindable"), + "`{} = \"{}\"` must be refused out loud", + asking.0, + asking.1, + ); + + let mut app = working(); + app.set_keys(keys); + assert_eq!( + app.key(control('c')), + Command::Interrupt, + "`{} = \"{}\"` took the stop key away from a running turn", + asking.0, + asking.1, + ); + assert_eq!(app.key(control('c')), Command::Abandon); + } +} + +/// `src/main.rs`, with every comment taken off before anything is matched, and +/// then every space. +/// +/// The stripping is the difference between a gate and a green light: `src/main.rs` +/// names `Steer::interrupt` in a comment explaining why it is *not* used, so a +/// gate that read the raw file would fail on the prose that documents the +/// decision it is asserting. `//` appears in no string literal in that file and it +/// has no block comments — `tests/structure.rs` rests on the same reading. The +/// whitespace goes because rustfmt decides where a line breaks. +fn driver_squashed() -> String { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/main.rs"); + let text = std::fs::read_to_string(path).expect("the driver is readable"); + text.lines() + .map(|line| match line.find("//") { + Some(at) => &line[..at], + None => line, + }) + .collect::>() + .join("\n") + .chars() + .filter(|c| !c.is_whitespace()) + .collect() +} + +/// **F6, the mechanism.** The stop key reaches the run as `Flow::Cancel`, and not +/// through the steer inbox. +/// +/// This is a property of `src/main.rs`, which nothing under `tests/` links, so it +/// is read out of the source the way `tests/contract.rs` and `tests/structure.rs` +/// read the driver's other decisions. It is asserted in two halves, because +/// either one alone is satisfiable by the sabotage: +/// +/// 1. **The positive.** Both `Command::Interrupt` and `Command::Abandon` set the +/// canceller taken from the observer, and that flag is what `io_cli::bridge` +/// turns into `Flow::Cancel` — asserted here against the real bridge rather +/// than assumed from the driver's variable name. +/// 2. **The negative.** The driver calls nothing's `interrupt`. `tests/steer.rs` +/// greps for the absence of `steer.interrupt()`; the absence on its own would +/// still pass with the arms rewritten to send a `Steering` some other way, so +/// the positive above is what makes this a gate. +/// +/// Sabotage: route the interrupt through `Steer::interrupt`. Only F6 fails — +/// the key still stops the turn, at the same boundary, with the same outcome, so +/// nothing on screen and no other test moves; what changes is which code in +/// io-harness records the outcome, and that every sentence this product has +/// written about its stop key becomes wrong. +#[test] +fn f6_the_stop_key_reaches_the_run_as_flow_cancel_and_not_the_steer_inbox() { + use io_harness::{Flow, Observer}; + + let driver = driver_squashed(); + + // (1) The flag exists, and it is the observer's rather than a bool of the + // driver's own — a local `stopped` proves nothing about the run. + assert!( + driver.contains("letcanceller=observer.canceller();"), + "the turn's stop is the observer's flag, taken from the bridge that watches it", + ); + + // Both arms set it. The second one is the one a refactor loses: it looks like + // the drop below it does the work, and dropping the future leaves io-harness + // with a run it never closed. + for arm in [ + "Command::Interrupt=>{canceller.store(true,std::sync::atomic::Ordering::Relaxed);\ + stopped=true;}", + "Command::Abandon=>{canceller.store(true,std::sync::atomic::Ordering::Relaxed);\ + stopped=true;", + ] { + assert!( + driver.contains(arm), + "the stop key sets the canceller and nothing else: {arm}", + ); + } + + // (2) The negative: no `interrupt` is called on anything, which is the one + // shape the sabotage takes. The comments are already gone, and the comment + // that explains this decision is the reason they had to be. + assert!( + !driver.contains(".interrupt("), + "`Steer::interrupt` reaches the same outcome by different code in io-harness; the \ + stop key is the canceller's and moving it buys an operator nothing they can see", + ); + assert!( + !driver.contains("Steer::interrupt"), + "not through a path built by hand either", + ); + + // And the flag is `Flow::Cancel`, against the real bridge: the two halves of + // the mechanism, joined, so the driver's variable name is not what this rests + // on. + let (observer, _events) = io_cli::bridge::channel(); + let canceller = observer.canceller(); + let event = RunEvent::new(1, 1, EventKind::Stalled); + assert_eq!(observer.event(&event), Flow::Continue); + canceller.store(true, std::sync::atomic::Ordering::Relaxed); + assert_eq!( + observer.event(&event), + Flow::Cancel, + "the flag the stop key sets is the one io-harness honours at the next step boundary", + ); +} + /// Feed one token and report how many lines it committed. fn app_lines(app: &mut App, text: &str) -> usize { let before = app.take_pending().len(); diff --git a/tests/palette.rs b/tests/palette.rs index 4bb5b11..2da5f0c 100644 --- a/tests/palette.rs +++ b/tests/palette.rs @@ -307,12 +307,12 @@ fn f1_an_exact_name_outranks_a_prefix_which_outranks_a_scattered_match() { #[test] fn f1_equal_scores_keep_the_first_row_still_between_keystrokes() { - // `c` matches SEVEN rows since 0.17.0: `copy`, `copy diff`, `contain`, - // `clear`, `config` and `context` all begin with it, and `mcp` merely holds - // one — which is the point of the arm, since a scattered match ranks below - // every prefix. `attach`, which also merely held one, stopped being a command - // in 0.13.1. `co` narrows to the five that begin with `co`: `clear` has no - // `o` after its `c`, and neither does `mcp`. + // `c` matches EIGHT rows since 0.17.0: `copy`, `copy diff`, `contain`, + // `clear`, `config`, `context` and `compact` all begin with it, and `mcp` + // merely holds one — which is the point of the arm, since a scattered match + // ranks below every prefix. `attach`, which also merely held one, stopped + // being a command in 0.13.1. `co` narrows to the six that begin with `co`: + // `clear` has no `o` after its `c`, and neither does `mcp`. // The three score the same and the tie-break is the order they were handed // in. The defect: an unstable sort swaps them on a keystroke that did not // change the result, and `Enter` takes a row nobody chose. @@ -323,14 +323,14 @@ fn f1_equal_scores_keep_the_first_row_still_between_keystrokes() { // quietly stopped happening. let mut picker = palette(); type_at(&mut picker, "c"); - assert_eq!(picker.matching(), 7); + assert_eq!(picker.matching(), 8); // `clear` and not `copy` since the list became grouped: the tie-break is the // order the rows were handed in, and the session group now leads. The // PROPERTY is unchanged and is what this test is about — the marked row must // not move on a keystroke that did not change the result. assert_eq!(marked(&picker), "clear"); type_at(&mut picker, "o"); - assert_eq!(picker.matching(), 5); + assert_eq!(picker.matching(), 6); assert_eq!( marked(&picker), "contain", diff --git a/tests/queue_edit.rs b/tests/queue_edit.rs new file mode 100644 index 0000000..0f9d5ed --- /dev/null +++ b/tests/queue_edit.rs @@ -0,0 +1,841 @@ +//! F3 — a queued line is recalled, edited, dropped and reordered, and prompt +//! history still works. +//! +//! `tests/queue.rs` owns the state and `tests/queue_surface.rs` owns the drawing; +//! this file owns the *verbs*. Everything here is about a surface that has stopped +//! being a list an operator reads and become one they drive, and about the one +//! thing that had to keep working while it did. +//! +//! That one thing is prompt history. `Up` at the first line of the composer has +//! recalled the previous prompt since it was documented, it is in the shipped key +//! table, and `tests/docs.rs` mirrors that table into the README. The queue's +//! arrows and the composer's arrows are the same two keys, so the binding is only +//! correct if it is scoped: **inside the open surface**, with an empty prompt. +//! Bound at the bare composer instead it would work perfectly for every operator +//! with something queued and silently cost prompt history to every operator +//! without — a feature nobody asked to trade away, broken by a release about a +//! different one. `f3_with_the_surface_closed_up_still_recalls_the_previous_prompt` +//! is the test that kills that. +//! +//! The verbs are asserted twice over, and on purpose. `queue::Cursor` is proved +//! directly, because the arithmetic of a mark that survives a queue mutating +//! underneath it is worth asserting without a keyboard in the way; the same verbs +//! are then proved through `App::key` and the rendered viewport, because a verb +//! nothing routes a key to is a verb no operator has. + +mod support; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use io_cli::app::{App, Command, Mode}; +use io_cli::glyphs::{ASCII, UNICODE}; +use io_cli::queue::{self, Cursor, Put}; +use io_cli::theme::DARK; +use io_harness::{EventKind, RunEvent}; + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +fn shifted(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::SHIFT) +} + +/// Type a line and press `Enter`, the way an operator sends one. +fn send(app: &mut App, text: &str) -> Command { + for character in text.chars() { + app.key(key(KeyCode::Char(character))); + } + app.key(key(KeyCode::Enter)) +} + +/// Type a line without sending it. +fn typed(app: &mut App, text: &str) { + for character in text.chars() { + app.key(key(KeyCode::Char(character))); + } +} + +/// Empty the prompt the way an operator does: backspace until there is nothing +/// left. More presses than there are characters, because a press at an empty +/// prompt is a no-op and the count is not the assertion. +fn erase(app: &mut App) { + for _ in 0..64 { + app.key(key(KeyCode::Backspace)); + } +} + +/// A session with a turn genuinely in flight. `tests/queue_surface.rs`'s fixture, +/// with the streamed tail that keeps `App::undoable` false. +fn running() -> App { + let mut app = App::new(DARK, "a-model"); + started(&mut app); + app +} + +/// Put the session into a running turn. +fn started(app: &mut App) { + app.started(); + app.event( + &RunEvent::new( + 1, + 1, + EventKind::Started { + goal: "count the tests".into(), + provider: "test".into(), + }, + ), + std::time::Duration::ZERO, + ); + app.event( + &RunEvent::new( + 1, + 1, + EventKind::Token { + text: "STREAMING".into(), + }, + ), + std::time::Duration::ZERO, + ); +} + +/// A running session with `count` prompts typed into it, in order. +fn with_queue(count: usize) -> App { + let mut app = running(); + for at in 0..count { + assert_eq!( + send(&mut app, &format!("queued prompt {at}")), + Command::None, + "a prompt typed mid-turn is kept rather than sent", + ); + } + app +} + +/// The viewport's rows as text, blanks kept: a row's *index* is the assertion. +fn rows_at(app: &App, viewport: u16) -> Vec { + let (mut screen, _recorder) = support::screen_of(80, 40, viewport); + screen + .draw(|frame| app.render(frame, frame.area())) + .expect("frame"); + screen + .viewport_text() + .split('\n') + .map(str::to_string) + .collect() +} + +/// Where the row holding `needle` is, or a panic naming what was on screen. +fn row_of(rows: &[String], needle: &str) -> usize { + rows.iter() + .position(|row| row.contains(needle)) + .unwrap_or_else(|| panic!("no row holds {needle:?}: {rows:#?}")) +} + +/// The queued line the marker is on, as the rendered row shows it. +fn marked(rows: &[String]) -> Option<&str> { + rows.iter() + .find(|row| row.starts_with(DARK.glyphs.marker)) + .map(|row| row.trim_end()) +} + +/// The text in the prompt, as the rendered row shows it. +fn prompt_row(rows: &[String]) -> String { + let at = row_of(rows, io_cli::composer::PROMPT.trim_end()); + rows[at].trim_end().to_string() +} + +/// Twelve rows leaves the composer four to lend — see `tests/queue_surface.rs`, +/// which is where that arithmetic is argued. Enough that a queue of three is +/// drawn a row at a time and a mark can be read off the frame. +const TALL: u16 = 12; + +fn queue_of(count: usize) -> Vec { + (0..count).map(|at| format!("prompt {at}")).collect() +} + +// --------------------------------------------------------------------------- +// The verbs, without a keyboard +// --------------------------------------------------------------------------- + +/// The mark enters the list from the row nearest the prompt, moves, and stops at +/// both ends. +#[test] +fn the_mark_enters_from_the_bottom_and_clamps_at_both_ends() { + let mut cursor = Cursor::default(); + assert_eq!( + cursor.selection(3), + None, + "nothing is marked until an arrow" + ); + + assert!( + !cursor.move_by(1, 3), + "Down with nothing marked is not the queue's key: below the queue is the \ + composer, and the operator is already in it", + ); + assert_eq!(cursor.selection(3), None); + + assert!(cursor.move_by(-1, 3)); + assert_eq!( + cursor.selection(3), + Some(2), + "Up enters at the line nearest the prompt, which is the row above the caret", + ); + cursor.move_by(-1, 3); + cursor.move_by(-1, 3); + assert_eq!(cursor.selection(3), Some(0)); + cursor.move_by(-1, 3); + assert_eq!( + cursor.selection(3), + Some(0), + "the top is the top, not a wrap" + ); + cursor.move_by(1, 3); + assert_eq!(cursor.selection(3), Some(1)); + + // A queue that shrank under the mark. The read clamps rather than indexing + // past the end — the drain takes the oldest between turns, and a mark held + // across that must not be a panic in a renderer. + cursor.move_by(-1, 3); + cursor.move_by(1, 3); + cursor.move_by(1, 3); + assert_eq!(cursor.selection(3), Some(2)); + assert_eq!(cursor.selection(2), Some(1)); + assert_eq!(cursor.selection(0), None); +} + +/// The mark travels with the line it is on, which is what makes three presses +/// move a line three places. +#[test] +fn a_reordered_line_carries_the_mark_with_it() { + let mut waiting = queue_of(3); + let mut cursor = Cursor::default(); + cursor.move_by(-1, waiting.len()); + assert_eq!(cursor.selection(waiting.len()), Some(2)); + + assert!(cursor.reorder(-1, &mut waiting)); + assert_eq!(waiting, ["prompt 0", "prompt 2", "prompt 1"]); + assert_eq!( + cursor.selection(waiting.len()), + Some(1), + "the mark followed the text: a mark that stayed on the slot would move a \ + different line on the next press", + ); + + assert!(cursor.reorder(-1, &mut waiting)); + assert_eq!(waiting, ["prompt 2", "prompt 0", "prompt 1"]); + assert!( + !cursor.reorder(-1, &mut waiting), + "the first line has nowhere further up to go", + ); + assert_eq!( + waiting, + ["prompt 2", "prompt 0", "prompt 1"], + "and nothing moved" + ); + + assert!(cursor.reorder(1, &mut waiting)); + assert_eq!(waiting, ["prompt 0", "prompt 2", "prompt 1"]); + + // Nothing marked is nothing to move. + let mut fresh = Cursor::default(); + assert!(!fresh.reorder(-1, &mut waiting)); +} + +/// A line goes out of the queue to be edited and comes back at its own position. +#[test] +fn an_edited_line_goes_back_where_it_came_from() { + let mut waiting = queue_of(3); + let mut cursor = Cursor::default(); + cursor.move_by(-1, waiting.len()); + cursor.move_by(-1, waiting.len()); + assert_eq!(cursor.selection(waiting.len()), Some(1)); + + assert_eq!(cursor.take(&mut waiting).as_deref(), Some("prompt 1")); + assert_eq!( + waiting, + ["prompt 0", "prompt 2"], + "it is OUT of the queue while it is being edited — one copy of a prompt \ + in the session, so a turn that ends mid-edit cannot run it twice", + ); + assert_eq!(cursor.editing(), Some(1)); + assert!( + cursor.take(&mut waiting).is_none(), + "a second take would be a second line out of the queue for one prompt", + ); + + assert_eq!( + cursor.put_back(&mut waiting, "prompt 1, edited"), + Some(Put::Kept(1)), + ); + assert_eq!(waiting, ["prompt 0", "prompt 1, edited", "prompt 2"]); + assert_eq!(cursor.editing(), None); + assert_eq!( + cursor.put_back(&mut waiting, "again"), + None, + "with no edit in flight the key was never the surface's", + ); +} + +/// The position survives the queue draining underneath the edit. +#[test] +fn an_edit_that_outlived_a_drain_goes_back_at_the_end() { + let mut waiting = queue_of(3); + let mut cursor = Cursor::default(); + cursor.move_by(-1, waiting.len()); + assert_eq!(cursor.take(&mut waiting).as_deref(), Some("prompt 2")); + + // The driver took the two in front of it while the edit was open. + waiting.clear(); + assert_eq!( + cursor.put_back(&mut waiting, "prompt 2"), + Some(Put::Kept(0)) + ); + assert_eq!( + waiting, + ["prompt 2"], + "past the end is the end, which is still 'after everything in front of it'", + ); +} + +/// Erased and sent back is dropped, and the text comes back so the operator can +/// be told what went. +#[test] +fn an_emptied_line_is_dropped_rather_than_queued_as_an_empty_turn() { + let mut waiting = queue_of(3); + let mut cursor = Cursor::default(); + cursor.move_by(-1, waiting.len()); + cursor.move_by(-1, waiting.len()); + cursor.take(&mut waiting); + + for erased in ["", " ", "\n\t "] { + let mut queue = waiting.clone(); + let mut aside = cursor.clone(); + assert_eq!( + aside.put_back(&mut queue, erased), + Some(Put::Dropped("prompt 1".to_string())), + "an empty prompt is not a prompt, so there is no turn to schedule", + ); + assert_eq!(queue, ["prompt 0", "prompt 2"]); + assert_eq!(aside.editing(), None); + } +} + +/// `Esc` puts the line back as it was taken, not as it was left. +#[test] +fn cancelling_an_edit_restores_the_text_it_started_with() { + let mut waiting = queue_of(3); + let mut cursor = Cursor::default(); + cursor.move_by(-1, waiting.len()); + cursor.move_by(-1, waiting.len()); + cursor.take(&mut waiting); + + assert_eq!(cursor.cancel(&mut waiting), Some(1)); + assert_eq!( + waiting, + ["prompt 0", "prompt 1", "prompt 2"], + "a cancel that kept the half-edited text would be a cancel that still \ + changed the queue", + ); + assert_eq!(cursor.cancel(&mut waiting), None); +} + +/// A turn ending under an edit forgets the position and leaves the line alone. +#[test] +fn a_lapsed_edit_is_forgotten_and_never_re_queued() { + let mut waiting = queue_of(2); + let mut cursor = Cursor::default(); + cursor.move_by(-1, waiting.len()); + cursor.take(&mut waiting); + + cursor.lapsed(); + assert_eq!(cursor.editing(), None); + assert_eq!(cursor.selection(waiting.len()), None); + assert_eq!( + waiting, + ["prompt 0"], + "the line stays in the composer where the operator can see it — putting \ + it back would queue a second copy behind the drain that is starting", + ); +} + +// --------------------------------------------------------------------------- +// The mark, drawn +// --------------------------------------------------------------------------- + +/// The marker column is on every row or on none of them, and the rows nobody has +/// touched are the rows this surface always drew. +#[test] +fn the_marker_is_a_column_on_every_row_or_on_no_row() { + let waiting = queue_of(3); + + let plain = queue::rows_for(&waiting, None, 40, 4, &UNICODE); + assert_eq!( + plain, + queue::rows(&waiting, 40, 4, &UNICODE), + "with nothing marked this is the surface it has always been", + ); + assert!(plain[0].starts_with("1. "), "{plain:#?}"); + + let marked = queue::rows_for(&waiting, Some(1), 40, 4, &UNICODE); + assert!(marked[1].starts_with(UNICODE.marker), "{marked:#?}"); + assert!(marked[0].starts_with(" 1. "), "{marked:#?}"); + assert!(marked[2].starts_with(" 3. "), "{marked:#?}"); + // Two cells in both sets is what keeps every label in one column: a column + // drawn only on the marked row would shift that row out of line with its + // neighbours, which reads as the row having changed rather than been chosen. + for row in &marked { + assert!( + row.chars().nth(2).is_some_and(|at| at.is_ascii_digit()), + "the number starts in the same column on every row: {row:?}", + ); + } + + // And an index that outlived its line is clamped rather than a panic. + let stale = queue::rows_for(&waiting, Some(99), 40, 4, &UNICODE); + assert!(stale[2].starts_with(UNICODE.marker), "{stale:#?}"); +} + +/// The window follows the mark when the queue is longer than the rows, and the +/// numbers stay absolute while it does. +#[test] +fn the_rows_scroll_to_keep_the_marked_line_on_screen() { + let waiting = queue_of(9); + + let top = queue::rows_for(&waiting, None, 40, 4, &UNICODE); + assert!(top[0].contains("1. prompt 0"), "{top:#?}"); + + let deep = queue::rows_for(&waiting, Some(7), 40, 4, &UNICODE); + assert_eq!(deep.len(), 4, "it draws into the rows it was given"); + assert!( + deep.iter().any(|row| row.starts_with(UNICODE.marker)), + "a mark the rows never show is an arrow that appears to do nothing: {deep:#?}", + ); + assert!(deep[2].contains("8. prompt 7"), "{deep:#?}"); + assert!( + deep[0].contains("6. prompt 5"), + "the numbers are the run order, not the row: a window that renumbered \ + itself from one would say the marked line runs first. {deep:#?}", + ); + assert!(deep[3].contains("6 more"), "{deep:#?}"); + + // The window never runs past the end. + let last = queue::rows_for(&waiting, Some(8), 40, 4, &UNICODE); + assert!(last[2].contains("9. prompt 8"), "{last:#?}"); +} + +/// At the one row a running turn can spare, the row is the marked line. +/// +/// The surface's whole visible budget at `term::VIEWPORT_HEIGHT` is a single row. +/// A mark that the row did not follow would be a selection an operator cannot +/// see, moved by arrows that appear to do nothing — which is worse than no +/// selection at all. +#[test] +fn at_one_row_the_row_is_the_marked_line() { + let waiting = queue_of(3); + + let quiet = queue::rows_for(&waiting, None, 40, 1, &UNICODE); + assert_eq!(quiet.len(), 1); + assert!(quiet[0].contains("1. prompt 0"), "{quiet:#?}"); + assert!(quiet[0].contains("2 more"), "{quiet:#?}"); + + let marked = queue::rows_for(&waiting, Some(2), 40, 1, &UNICODE); + assert_eq!(marked.len(), 1); + assert!(marked[0].starts_with(UNICODE.marker), "{marked:#?}"); + assert!(marked[0].contains("3. prompt 2"), "{marked:#?}"); + assert!( + marked[0].contains("2 more"), + "the count was never about what is below the row, only about what is not \ + on it: {marked:#?}", + ); +} + +/// The mark draws in the ASCII set too, and no row outgrows its width. +#[test] +fn a_marked_row_stays_ascii_and_inside_its_width() { + let waiting = queue_of(9); + let rows = queue::rows_for(&waiting, Some(4), 24, 4, &ASCII); + assert!(rows.iter().all(|row| row.is_ascii()), "{rows:#?}"); + assert!( + rows.iter().any(|row| row.starts_with(ASCII.marker)), + "{rows:#?}" + ); + + let long = vec!["a prompt long enough to need cutting at any width".to_string(); 4]; + for width in [10_u16, 24, 80] { + for row in queue::rows_for(&long, Some(2), width, 4, &UNICODE) { + assert!( + row.chars().count() <= usize::from(width), + "a row of {} characters at width {width}: {row:?}", + row.chars().count(), + ); + } + } +} + +// --------------------------------------------------------------------------- +// F3 — through the keyboard, on the frame +// --------------------------------------------------------------------------- + +/// F3 — `Up` and `Down` move the selection inside the surface. +#[test] +fn f3_up_and_down_move_the_selection_inside_the_surface() { + let mut app = with_queue(3); + assert!(app.queue_open()); + assert!( + marked(&rows_at(&app, TALL)).is_none(), + "a surface nobody has touched draws no mark", + ); + + assert_eq!(app.key(key(KeyCode::Up)), Command::None); + let rows = rows_at(&app, TALL); + assert_eq!( + marked(&rows).map(|row| row.contains("queued prompt 2")), + Some(true), + "Up enters at the line nearest the prompt: {rows:#?}", + ); + assert_eq!( + prompt_row(&rows), + io_cli::composer::PROMPT.trim_end(), + "and it is the queue that moved, not the composer: the arrow inside the \ + surface must not put a recalled prompt in the prompt. {rows:#?}", + ); + + app.key(key(KeyCode::Up)); + assert_eq!( + marked(&rows_at(&app, TALL)).map(|row| row.contains("queued prompt 1")), + Some(true), + ); + app.key(key(KeyCode::Down)); + assert_eq!( + marked(&rows_at(&app, TALL)).map(|row| row.contains("queued prompt 2")), + Some(true), + ); + + assert_eq!( + app.queued_prompts().len(), + 3, + "moving a mark moves nothing else", + ); +} + +/// F3 — the sabotage arm: the queue's `Up` bound at the bare composer. +/// +/// **This is the test that kills it.** With the surface shut, `Up` at the first +/// line of the composer is prompt history and has been since it was documented — +/// it is in `commands::KEYS`, which `tests/docs.rs` mirrors into the README. A +/// binding that moved a queue selection here would leave the operator's own +/// previous prompt unreachable, in a release that was about a different feature +/// entirely, and would do it *silently*: the surface is not on screen, so there is +/// nothing to watch the arrow move. +/// +/// The queue is deliberately non-empty. A sabotage that only checked "is anything +/// queued" would pass against an empty one. +#[test] +fn f3_with_the_surface_closed_up_still_recalls_the_previous_prompt() { + let mut app = App::new(DARK, "a-model"); + assert_eq!( + send(&mut app, "the earlier prompt"), + Command::Submit("the earlier prompt".to_string()), + ); + started(&mut app); + send(&mut app, "queued prompt 0"); + send(&mut app, "queued prompt 1"); + + assert_eq!(app.key(key(KeyCode::Esc)), Command::None); + assert!(!app.queue_open(), "the surface is shut and drawing nothing"); + assert_eq!( + app.queued_prompts().len(), + 2, + "and the queue is still there" + ); + + assert_eq!(app.key(key(KeyCode::Up)), Command::None); + let rows = rows_at(&app, TALL); + assert!( + prompt_row(&rows).contains("queued prompt 1"), + "Up at the first line of a shut surface is prompt history, which is what \ + it has done since it was documented — and the newest entry is the line \ + submitted last, queued or not: {rows:#?}", + ); + + // And the walk keeps going, which a mark being moved instead could not fake. + app.key(key(KeyCode::Up)); + app.key(key(KeyCode::Up)); + let rows = rows_at(&app, TALL); + assert!( + prompt_row(&rows).contains("the earlier prompt"), + "three presses reach the prompt sent before the turn began: {rows:#?}", + ); + assert_eq!( + app.queued_prompts().len(), + 2, + "and it moved nothing in the queue", + ); + assert!( + marked(&rows).is_none(), + "nor marked a row of a surface that is not on screen: {rows:#?}", + ); +} + +/// F3 — a line is recalled into the composer, edited, and put back at its own +/// position, without becoming a turn of its own. +#[test] +fn f3_a_line_is_edited_in_the_composer_and_put_back_at_its_own_position() { + let mut app = with_queue(3); + app.key(key(KeyCode::Up)); + app.key(key(KeyCode::Up)); + + assert_eq!( + app.key(key(KeyCode::Enter)), + Command::None, + "Enter at an empty prompt takes the marked line into the composer", + ); + assert_eq!( + app.queued_prompts(), + ["queued prompt 0", "queued prompt 2"], + "the line is out of the queue while it is being edited", + ); + let rows = rows_at(&app, TALL); + assert!( + prompt_row(&rows).contains("queued prompt 1"), + "and it is in the prompt, where it can be seen: {rows:#?}", + ); + + typed(&mut app, ", edited"); + assert_eq!( + app.key(key(KeyCode::Enter)), + Command::None, + "finishing an edit is not sending a turn: a Submit here would run the \ + line the operator was still writing", + ); + assert_eq!( + app.queued_prompts(), + [ + "queued prompt 0", + "queued prompt 1, edited", + "queued prompt 2" + ], + "back at its own position — an edit that appended would have reordered \ + the next few turns as the price of fixing a typo", + ); + assert_eq!( + app.mode(), + Mode::Running, + "and the turn underneath is untouched" + ); + let rows = rows_at(&app, TALL); + assert_eq!( + prompt_row(&rows), + io_cli::composer::PROMPT.trim_end(), + "the composer is empty again: {rows:#?}", + ); +} + +/// F3 — an edit never starts on top of a half-typed prompt. +/// +/// The composer is shared with the line being written, and there is exactly one +/// of it. The surface's answer is to not claim `Enter` while there is text in the +/// prompt: the keystroke goes on meaning what it has meant all release — queue +/// this line — and the operator's half-typed prompt is kept rather than +/// overwritten by a queued one. +#[test] +fn f3_an_edit_never_starts_on_top_of_a_half_typed_prompt() { + let mut app = with_queue(2); + app.key(key(KeyCode::Up)); + typed(&mut app, "half typed"); + + let before = rows_at(&app, TALL); + let marked_before = marked(&before).map(str::to_string); + assert_eq!( + app.key(key(KeyCode::Up)), + Command::None, + "the arrow is the composer's while there is something in it", + ); + // **The surface did not take it — the composer did, and it did what it has + // always done.** `Up` at the first line recalls the previous prompt and stashes + // the draft, which is shipped behaviour the key table documents; `Down` brings + // the draft back. So what F3 is owed here is not "the text is untouched" — the + // first draft of this test asserted that and failed against a feature older + // than the queue — but that the QUEUE'S mark did not move. The key went to one + // owner, and it was not this surface. + let rows = rows_at(&app, TALL); + assert_eq!( + marked(&rows).map(str::to_string), + marked_before, + "the surface took an arrow that belonged to the composer: {rows:#?}", + ); + app.key(key(KeyCode::Down)); + let rows = rows_at(&app, TALL); + assert!( + prompt_row(&rows).contains("half typed"), + "and the composer's own recall gave the draft back: {rows:#?}", + ); + + assert_eq!(app.key(key(KeyCode::Enter)), Command::None); + assert_eq!( + app.queued_prompts(), + ["queued prompt 0", "queued prompt 1", "half typed"], + "Enter with text in the prompt queues that text, exactly as it did before \ + the surface had any keys at all", + ); + + // And now that the prompt is empty, the same key takes the marked line. + app.key(key(KeyCode::Enter)); + assert_eq!( + app.queued_prompts(), + ["queued prompt 0", "half typed"], + "the mark stayed on the line it was on while the queue grew under it", + ); +} + +/// F3 — a line is dropped: erased in the composer and sent back to nothing. +#[test] +fn f3_a_queued_line_is_dropped() { + let mut app = with_queue(3); + app.key(key(KeyCode::Up)); + app.key(key(KeyCode::Up)); + app.key(key(KeyCode::Enter)); + assert_eq!(app.queued_prompts(), ["queued prompt 0", "queued prompt 2"]); + + erase(&mut app); + assert_eq!( + app.key(key(KeyCode::Enter)), + Command::None, + "an emptied line is not submitted either", + ); + assert_eq!( + app.queued_prompts(), + ["queued prompt 0", "queued prompt 2"], + "it is gone: an empty prompt is not a prompt, so there is no turn left to \ + schedule for it", + ); + assert_eq!(app.mode(), Mode::Running); + let rows = rows_at(&app, TALL); + // **The queue no longer lists it, and the notice names it.** Those are two + // different jobs and the first draft of this test conflated them: it swept the + // whole frame for the text and failed on io-cli's own `dropped "…"` sentence. + // A drop that went unnamed would be the worse product — the operator erased a + // line and pressed a key, and what they get back is which line went and how + // many are left. + let listed: Vec<&String> = rows + .iter() + .take_while(|row| !row.contains(io_cli::composer::PROMPT.trim_end())) + .collect(); + assert!( + !listed.iter().any(|row| row.contains("queued prompt 1")), + "the queue above the composer no longer lists it: {rows:#?}", + ); + let notice = app + .status + .notice + .as_ref() + .map(|(_, text)| text.clone()) + .unwrap_or_default(); + assert!( + notice.contains("dropped") && notice.contains("queued prompt 1"), + "and the operator is told which line went: {notice:?}", + ); +} + +/// F3 — `Esc` during an edit puts the line back exactly as it was taken. +/// +/// 0.13.1's rule, in the one place this release can honour it: an erase is +/// undoable where the undo is cheap, and one string for the length of one edit is +/// as cheap as undo gets. It is also why the drop above is a deliberate act +/// rather than a slip — everything up to the `Enter` is `Esc`-able. +#[test] +fn f3_esc_during_an_edit_puts_the_line_back_unchanged() { + let mut app = with_queue(3); + app.key(key(KeyCode::Up)); + app.key(key(KeyCode::Up)); + app.key(key(KeyCode::Enter)); + erase(&mut app); + typed(&mut app, "something else entirely"); + + assert_eq!(app.key(key(KeyCode::Esc)), Command::None); + assert_eq!( + app.queued_prompts(), + ["queued prompt 0", "queued prompt 1", "queued prompt 2"], + "cancelled means the queue is as it was, not as the half-edit left it", + ); + assert_eq!( + app.mode(), + Mode::Running, + "the Esc that answered the edit did not reach the turn", + ); + let rows = rows_at(&app, TALL); + assert_eq!( + prompt_row(&rows), + io_cli::composer::PROMPT.trim_end(), + "and the composer is clear for the next line: {rows:#?}", + ); +} + +/// F3 — two lines swap order, and the mark travels with the line. +#[test] +fn f3_two_queued_lines_swap_order() { + let mut app = with_queue(3); + app.key(key(KeyCode::Up)); + app.key(key(KeyCode::Up)); + + assert_eq!(app.key(shifted(KeyCode::Up)), Command::None); + assert_eq!( + app.queued_prompts(), + ["queued prompt 1", "queued prompt 0", "queued prompt 2"], + "the marked line moved one place up the run order", + ); + let rows = rows_at(&app, TALL); + assert_eq!( + marked(&rows).map(|row| row.contains("queued prompt 1")), + Some(true), + "and the mark went with it, so a second press moves the same line: {rows:#?}", + ); + assert!( + row_of(&rows, "queued prompt 1") < row_of(&rows, "queued prompt 0"), + "the rows are drawn in the new order: {rows:#?}", + ); + + assert_eq!(app.key(shifted(KeyCode::Down)), Command::None); + assert_eq!( + app.queued_prompts(), + ["queued prompt 0", "queued prompt 1", "queued prompt 2"], + "and back", + ); + assert_eq!(app.mode(), Mode::Running, "reordering runs nothing"); +} + +/// F3 — the turn ending under an edit leaves the line in the prompt rather than +/// running it or losing it. +/// +/// The one question a shared composer has to answer out loud. The line is out of +/// the queue, so the drain cannot run it; it is in the prompt, so the operator can +/// see it and send it; and the position it came from is forgotten, because a slot +/// remembered across a drain points at somebody else's line. +#[test] +fn f3_a_turn_ending_under_an_edit_leaves_the_line_in_the_prompt() { + let mut app = with_queue(2); + app.key(key(KeyCode::Up)); + app.key(key(KeyCode::Enter)); + assert_eq!(app.queued_prompts(), ["queued prompt 0"]); + + app.finished(); + assert_eq!(app.mode(), Mode::Idle); + assert!(!app.queue_open(), "no turn to be queued behind"); + let rows = rows_at(&app, TALL); + assert!( + prompt_row(&rows).contains("queued prompt 1"), + "the line being edited is still in the prompt: {rows:#?}", + ); + assert_eq!( + app.queued_prompts(), + ["queued prompt 0"], + "and it was not put back behind the drain, which would run it twice", + ); + + // At an idle prompt it is an ordinary line again, and Enter sends it. + assert_eq!( + app.key(key(KeyCode::Enter)), + Command::Submit("queued prompt 1".to_string()), + ); +} diff --git a/tests/steer.rs b/tests/steer.rs index b68ab88..f4fb439 100644 --- a/tests/steer.rs +++ b/tests/steer.rs @@ -134,10 +134,20 @@ fn f5_the_stop_key_stays_on_the_observers_flag() { "`Ctrl+C` is the canceller's, and giving it a second mechanism changes which path records \ the outcome", ); + // A fold IS offered, by `/compact`, and it is a third thing the operator sends + // rather than a second thing the stop key does. What this pins is that the two + // stay separate: the fold rides the inbox because it is a message, the stop + // rides the flag because it is not, and neither is reachable from the other's + // key. An earlier draft of this test asserted the fold was absent — written + // before `/compact` landed in the same release, which is what a gate written + // against a half-built tree pins. assert!( - !text.contains("steer.fold()"), - "a fold is a third thing an operator can send and this release does not offer it; a call \ - with no key behind it is a path nothing can reach", + text.contains("steer.fold()"), + "`/compact` sends the fold through the inbox, beside the words `/steer` sends", + ); + assert!( + !text.contains("canceller") || !text.contains("steer.fold();\n"), + "the fold is the operator's third message and never the stop key's second meaning", ); } diff --git a/tests/timing.rs b/tests/timing.rs index 4c6bab6..cde17fb 100644 --- a/tests/timing.rs +++ b/tests/timing.rs @@ -11,12 +11,23 @@ //! driver. Something has to read a real clock; the driver is the only thing that //! may, and it is the reason nothing else has to. The second test below is what //! keeps that "only". +//! +//! **0.17.0 widens the second test in three ways, and relaxes nothing.** The +//! release adds a mid-turn prompt queue, and a queue is the single most natural +//! place in this product for a clock to appear: an entry wants a timestamp, a +//! burst of keystrokes wants a debounce, and both are one `now()` away. So the +//! sweep that used to look for one spelling in one directory level now looks for +//! all three spellings the criterion names, in every file under `src/` however +//! deeply it is nested, and refuses the aliases that would let a fourth spelling +//! through. See `aliases_a_clock` for the last of those, which is the hole a +//! string match always leaves. -use std::path::Path; +use std::path::{Path, PathBuf}; -/// The needles, assembled from pieces so that this file does not match itself. -/// A scanner that has to skip its own path is a scanner with a hole in it. -fn forbidden() -> Vec<(String, &'static str)> { +/// The two ways a test can wait, assembled from pieces so that this file does not +/// match itself. A scanner that has to skip its own path is a scanner with a hole +/// in it exactly the size of the thing it skips. +fn sleeps() -> Vec<(String, &'static str)> { vec![ ( format!("thread::{}", "sleep"), @@ -26,9 +37,21 @@ fn forbidden() -> Vec<(String, &'static str)> { format!("time::{}", "sleep"), "an async sleep is a wall-clock assertion wearing a different hat", ), + ] +} + +/// The three ways anything can ask what time it is. N1 names exactly these, and +/// until 0.17.0 only the first of them was swept for across `src/` — which meant +/// a module could have taken a wall clock, or measured a span off one it was +/// handed, without a gate saying so. Both halves of the file now use this list, +/// so the rule a test is held to and the rule a module is held to are one list +/// rather than two that drift. +fn clock_reads() -> Vec<(String, &'static str)> { + vec![ ( format!("{}::now", "Instant"), - "reading a clock in a test is how a timing assertion gets written", + "reading a clock is how a timing assertion, or a timestamped queue \ + entry, gets written", ), ( format!("{}::now", "SystemTime"), @@ -41,6 +64,73 @@ fn forbidden() -> Vec<(String, &'static str)> { ] } +fn forbidden() -> Vec<(String, &'static str)> { + let mut all = sleeps(); + all.extend(clock_reads()); + all +} + +/// Every needle above is a string, and a string match is defeated by a rename. +/// `use std::time::Instant as Clock` puts `Clock::now()` in a module that no +/// needle in this file will ever see, and so does a glob over the time module or +/// a re-export renamed on its way through. There is no honest reason to alias a +/// clock, so the alias is forbidden outright rather than chased — the same move +/// `tests/dependencies.rs` makes for `use std::process as`, for the same reason: +/// it leaves one string for this test, and for a reader, to look for. +/// +/// Deliberately scoped to import lines. This crate's modules argue their design +/// in prose, and prose that says "treat the Instant as the session's age" is a +/// file explaining itself, not a file hiding a clock. A gate that reddened on +/// that sentence would be a gate somebody weakens next release, which is worse +/// than one that is narrow. +fn aliases_a_clock(line: &str) -> Option<&'static str> { + let line = line.trim_start(); + let rest = line + .strip_prefix("pub use ") + .or_else(|| line.strip_prefix("use "))?; + + let time = format!("std::{}", "time"); + if rest.starts_with(&format!("{time}::*")) { + return Some("a glob over the time module brings a clock in without naming it"); + } + if rest.starts_with(&format!("{time} as ")) { + return Some("the time module renamed is every clock in it renamed"); + } + if rest.contains(" as ") && (rest.contains("Instant") || rest.contains("SystemTime")) { + return Some("a clock imported under another name is a clock no string match finds"); + } + None +} + +/// Every `.rs` file under `src/`, at any depth. +/// +/// Recursive since 0.17.0. The previous sweep read one directory level, which was +/// true of `src/` and would have stayed true right up until the release that +/// grows a submodule directory — at which point the gate would have kept passing +/// while covering less, and nothing would have said so. `tests/dependencies.rs` +/// has walked the tree since 0.1.0; this is the same walk, and the two now agree +/// about what "every source file" means. +fn crate_sources() -> Vec<(PathBuf, String)> { + let mut found = Vec::new(); + let mut stack = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; + while let Some(directory) = stack.pop() { + for entry in std::fs::read_dir(&directory) + .unwrap_or_else(|error| panic!("{} is readable: {error}", directory.display())) + .flatten() + { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path.extension().is_some_and(|extension| extension == "rs") { + let source = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("{} is readable: {error}", path.display())); + found.push((path, source)); + } + } + } + found +} + /// Every `.rs` file under `tests/`, including this one. fn test_sources() -> Vec<(std::path::PathBuf, String)> { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests"); @@ -82,18 +172,19 @@ fn n1_no_test_sleeps_or_measures_elapsed_time() { "the scan did not even find itself", ); + let needles = forbidden(); let mut violations = Vec::new(); for (path, source) in &sources { - for (needle, why) in forbidden() { - for (number, line) in source.lines().enumerate() { - if line.contains(&needle) { - violations.push(format!( - "{}:{}: {needle} — {why}", - path.display(), - number + 1, - )); + for (number, line) in source.lines().enumerate() { + let at = format!("{}:{}", path.display(), number + 1); + for (needle, why) in &needles { + if line.contains(needle.as_str()) { + violations.push(format!("{at}: {needle} — {why}")); } } + if let Some(why) = aliases_a_clock(line) { + violations.push(format!("{at}: {} — {why}", line.trim())); + } } } @@ -106,21 +197,51 @@ fn n1_no_test_sleeps_or_measures_elapsed_time() { #[test] fn n1_the_driver_is_the_only_thing_that_reads_a_clock() { - let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let driver = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/main.rs"); + let sources = crate_sources(); + + // The same tripwire the sweep above carries, for the same reason: a walk that + // found nothing passes every assertion below it, and the pass would be + // indistinguishable from the real one. + assert!( + sources.len() >= 20, + "the walk found only {} source files, which means it is not walking", + sources.len(), + ); + + let needles = clock_reads(); let mut readers = Vec::new(); - for entry in std::fs::read_dir(&src).expect("src is readable").flatten() { - let path = entry.path(); - if path.extension().is_none_or(|extension| extension != "rs") { + let mut aliases = Vec::new(); + for (path, source) in &sources { + // The driver is the one module permitted to read a clock — it is not + // permitted to reach one under a false name, because the permission is + // what makes every other module's `age` argument trustworthy and an + // aliased clock in the driver is a clock a reader of `main.rs` cannot + // find either. + for (number, line) in source.lines().enumerate() { + if let Some(why) = aliases_a_clock(line) { + aliases.push(format!( + "{}:{}: {} — {why}", + path.display(), + number + 1, + line.trim(), + )); + } + } + + if path == &driver { continue; } - let source = std::fs::read_to_string(&path).expect("a source file is readable"); - if source.contains(&format!("{}::now", "Instant")) { - readers.push( - path.file_name() - .expect("a file has a name") - .to_string_lossy() - .into_owned(), - ); + for (needle, why) in &needles { + for (number, line) in source.lines().enumerate() { + if line.contains(needle.as_str()) { + readers.push(format!( + "{}:{}: {needle} — {why}", + path.display(), + number + 1, + )); + } + } } } @@ -129,13 +250,39 @@ fn n1_the_driver_is_the_only_thing_that_reads_a_clock() { // the same everywhere and the assertion is about the set rather than the // directory's mood. readers.sort(); + aliases.sort(); + + assert!( + aliases.is_empty(), + "a clock is reached under another name:\n{}", + aliases.join("\n"), + ); // One reader, and it is the driver. Every other module is handed the age it // needs, which is what makes the session's liveness testable at all — and - // what stops a second, unsynchronised clock appearing in a later release. - assert_eq!( - readers, - vec!["main.rs".to_string()], - "something other than the driver started reading a clock", + // what stops a second, unsynchronised clock appearing in a later release. A + // queue entry stamped when it was queued is the shape this release makes + // likeliest, and it fails here by name. + assert!( + readers.is_empty(), + "something other than the driver started reading a clock:\n{}", + readers.join("\n"), + ); + + // And the driver still reads one, spelled in full. A gate watching for a + // string is a gate that stops covering anything the day the string moves, so + // the string is asserted present as well as absent — the same pairing + // `tests/dependencies.rs` makes for `std::process::Command` in `src/shell.rs`. + // Taken from the walk rather than read again, which also says the walk found + // the one file it exempts. + let (_, source) = sources + .iter() + .find(|(path, _)| path == &driver) + .expect("the walk found src/main.rs, which is the file it exempts"); + assert!( + source.contains(&format!("{}::now", "Instant")), + "src/main.rs no longer reads a clock in full spelling. Either the session's \ + age comes from somewhere this file cannot see, or the needle above is \ + watching a string that is no longer written anywhere.", ); } From 945a245cccdc5efaaa9762792ff61963018f43de Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Wed, 26 Aug 2026 09:38:15 +0530 Subject: [PATCH 08/12] fix(status): ctx% against the window you configured, and the docs stop lying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **`ctx N%` has been wrong since the field was added, and silently.** It divided by `ContextBudget::default()` with no remaining allowance, so an operator who set `[run.context]` — or `[run] max_tokens`, which the harness takes a share of — saw a percentage of a window they do not have. The comment above it claimed the denominator was "io-harness's own declared budget, asked of the harness rather than copied here", which was the shape of the answer without being the answer. The window now comes from the contract the turn is running under, through `Budgets::in_force` — which the driver already assigns at startup and once a turn, so the denominator costs no new driver line and cannot drift from the other budgets. No window means no field, rather than a fallback: a loud absence beats a plausible wrong number, which is the whole lesson of this defect. And it no longer waits for a fold. The numerator is the per-step assembly the harness already makes durable — `ContextEvent::assembled.est_tokens`, read once per step on the same anchor `commit_edits` uses, not once per event. `/status` now names the window even before the first turn, because that half is the contract's and is knowable at an idle prompt; it is the half an operator checking `[run.context]` came to the page for, and the old sentence withheld it. Note it is `[run.context]` and not a top-level `[context]` table — the root refuses unknown keys, so the table the first draft of this contract named would have been rejected outright. **And roughly twenty sentences across fourteen files said a turn cannot be steered.** They were the most dangerous thing in the tree for this release: a reader trusting them would conclude the work needed a dependency change it did not. Corrected in the release that falsifies them, along with the claim that containment decides whether a turn can be steered — it decides fan-out, and as of this release that is the only thing it decides. Sentences that are still TRUE were left alone, and that is the harder half: `Session::turn` and `turn_steered` really do still build their own contracts, the interrupt really does stay on the observer's flag, and a delivered steer really cannot be confirmed from the event stream. One test's title was backwards in the other direction — it said a steered turn is never cancelled through the observer, which is exactly the mechanism. Two claims found while sweeping, neither about steering: `src/events.rs` argued from a contract lineage this crate does not have (the conclusion held — nothing here calls `with_verification`), and eight files still described a viewport of four rows, which has been eight since 0.13.0. `tests/docs.rs` gains a gate for this release's version of the mistake: every comment line under `src/` and `tests/`, plus the README, against the phrases that assert a turn cannot be steered. Comment-lines-only is what makes it safe — the absent-lists inside existing tests are code and do not redden — and it has a positive half too, because a gate made only of absences passes on a file that says nothing. The README gained the queue's keys in prose, outside the generated table. They are borrowed while the surface is open rather than bound all session, which is why they are not in the key table, and the paragraph says so. Before this the release's headline feature had no documentation surface at all. --- CHANGELOG.md | 71 ++++-- README.md | 20 +- src/app.rs | 41 +++- src/approval.rs | 12 +- src/commands.rs | 12 +- src/context.rs | 2 +- src/contract.rs | 10 + src/events.rs | 10 +- src/exec.rs | 8 + src/fleet.rs | 2 +- src/lib.rs | 6 +- src/main.rs | 38 ++- src/picker.rs | 4 +- src/queue.rs | 18 +- src/settings.rs | 13 +- src/status.rs | 200 ++++++++++++++- src/term.rs | 2 +- tests/approval.rs | 2 +- tests/contain.rs | 35 ++- tests/context_share.rs | 540 +++++++++++++++++++++++++++++++++++++++++ tests/docs.rs | 142 +++++++++++ tests/exec.rs | 7 +- tests/picker.rs | 2 +- tests/status.rs | 35 ++- tests/surfaces.rs | 2 +- 25 files changed, 1125 insertions(+), 109 deletions(-) create mode 100644 tests/context_share.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f0167ac..bb7b6b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,18 +19,41 @@ turn ends — one prompt per turn, each its own exchange in the scrollback and e interruptible. A turn you stop drops what was waiting, because one press of the stop key should not start the next three turns. -**A queued line can be sent into the turn that is still running.** io-harness -delivers it at the next step boundary, so the step in flight completes whole and -the agent reads the correction before it chooses what to do next. That is the -difference between redirecting an agent and killing it. It is not instant and the -interface says so: a tool call in flight is not a safe place to change the +**The queue is on screen, and it cost the session no height.** The waiting lines +are drawn above the prompt, the arrows mark one, the shifted arrows move it, and +`Enter` on an empty prompt takes it back into the composer to edit. The rows come +out of the blank row that has sat above the activity line since 0.13.0 — it is +lent to the queue while the queue is open and taken back the moment it closes. The +viewport is the eight rows it always was, the composer keeps every row it has, and +the alternative — a frame that grew by one row per line typed — would have walked +the conversation off the top of the screen exactly when it was worth reading. + +**`/steer` sends what is queued into the turn that is still running.** +io-harness delivers it at the next step boundary, so the step in flight completes +whole and the agent reads the correction before it chooses what to do next. That +is the difference between redirecting an agent and killing it. It is a word you +type rather than something a queued line does by itself, and that is deliberate: a +delivered steer emits no event this interface can draw, so a line that went on its +own would leave the screen with no echo at all. It is not instant and the +interface says so — a tool call in flight is not a safe place to change the conversation out from under. -**`Ctrl+C` means exactly what it meant.** It is the instrument for stopping, and -this is the release that stops it from being the only instrument for anything. It -still reaches the run the same way it always has, it still pre-empts an approval, -an intent question and a plan gate, and it is still refused as a rebindable -chord. +**A contained turn can be steered now, and that is the last thing containment +decided.** Through io-harness 0.66 no session entry point took a caller's +containment and a steer inbox on one call, so `[app.io-cli.containment]` bought a +fan-out and charged a mid-turn correction for it. 0.67.0 opened +`turn_bounded_steered` and `turn_contained_bounded_steered` — the same two calls +with an inbox appended — and io-cli takes both. `/contain` decides fan-out and +nothing else. + +**`Ctrl+C` means exactly what it meant, and this release is where it was decided +not to move it.** Both arms now hold a `SteerInbox`, so `Steer::interrupt` would +reach the same `RunOutcome::Cancelled` at the same step boundary — and the stop +key stays on the observer's cancellation flag, where it has been since 0.1.0. The +two paths are recorded by different code in io-harness, an operator cannot tell +them apart from the screen, and this is the one key no configuration file may +rebind. It still pre-empts an approval, an intent question and a plan gate, and it +is still refused as a rebindable chord. **`/context` says what is actually in the model's window.** The system block, the tool catalogue, the repository's instructions, each MCP server's tools, the @@ -41,10 +64,13 @@ catalogue includes tools io-cli never registered: it is the catalogue the model was handed. **`/compact` folds the conversation when you know a long thread is finished**, -rather than when a threshold notices. It reports what folded, not what was asked -for — a request over a conversation shorter than the fold's own floor does -nothing, and with compaction turned off it does nothing, and in both cases the -line says so instead of claiming a fold. +rather than when a threshold notices. It has two real triggers rather than one +word with two meanings: typed at an idle prompt it arms the next turn to fold at +its first step, and typed while a turn is running it goes down the same channel +`/steer` does, as `Steer::fold`, and lands at the next step boundary. It reports +what folded, not what was asked for — a request over a conversation shorter than +the fold's own floor does nothing, and with compaction turned off it does nothing, +and in both cases the line says so instead of claiming a fold. **`/mcp` says how many tools a server offered**, beside how many have been asked for. Two different questions, two different numbers. A server that offered @@ -61,12 +87,17 @@ in which the number was worth having. number has counted calls since 0.10.0; it was the one site 0.16.0's rename missed. -**io-harness 0.67 → 0.69.** Two breaking changes reach this crate and both land -in tests rather than in what you run: `EventKind::Mcp` gained the offered-tool -count, and `SteerInbox::pending` returns a struct rather than a tuple so that the -third thing an operator can send did not have to grow it. No dependency was -added, the feature list is unchanged, and every test that existed before this -release still passes unchanged. +**io-harness 0.67 → 0.69.** 0.67.0 is what made this release possible: +`Session::turn_bounded_steered` and `Session::turn_contained_bounded_steered` take +a caller's `TaskContract`, an observer **and** a `SteerInbox` on one call, so +neither arm has to give one up to get the other. The inbox is a parameter of the +drive call rather than a field of the contract, which is why `contract.rs` needed +no change at all. Two breaking changes reach this crate and both land in tests +rather than in what you run: `EventKind::Mcp` gained the offered-tool count, and +`SteerInbox::pending` returns a struct rather than a tuple so that the third thing +an operator can send did not have to grow it. No dependency was added, the feature +list is unchanged, and every test that existed before this release still passes +unchanged. ## [0.16.0] - 2026-08-25 diff --git a/README.md b/README.md index 419daa1..8a66105 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,19 @@ rather than leaving you to press it and watch a half-written prompt go to the model. Nothing about the composer changed: `Shift+Enter`, `Alt+Enter`, `Ctrl+J` and a trailing `\` all still work wherever the terminal can distinguish them. +**Four more keys exist only while something is queued**, and they are deliberately +not in the table above: that table is what the session binds all the time, and +these are borrowed by the queue surface for as long as it is open and handed back +the moment it shuts. While a prompt is waiting behind a running turn, the arrows +mark a queued line instead of walking prompt history, `Shift`+the arrows move the +marked line up and down the queue, `Enter` on an empty prompt takes the marked +line back into the composer to edit — `Enter` again puts it back where it was — +and `Esc` abandons an edit in progress, or closes the surface and gives all four +keys back. Every other key still falls through to the composer, because typing is +how the next line joins the queue. It is the same trade the fleet view makes with +the same two arrows, and `/steer` is what sends the queue into the turn rather +than waiting for it. + ### Moving a key The keys the session itself owns can be rebound in `[app.io-cli.keys]`, by action @@ -787,9 +800,10 @@ contained turn was the only session entry point io-harness let a caller hand a task contract to, so the responder, the plan gate, MCP servers, language servers, the browser and the skills directory all arrived on that one switch. 0.11.0 gave the ordinary turn a contract too. Every one of those has been on every turn since, -contained or not, and no session turn takes a steer inbox — so containment costs -no steering and grants no capability. It is the caps a fan-out runs under and -nothing else. +contained or not — and since 0.17.0 every session turn carries a steer inbox as +well, so a contained turn can be steered mid-flight exactly as an ordinary one +can. Containment grants no capability and costs no steering. It is the caps a +fan-out runs under and nothing else. **What changes for a file you already have.** No key is added, removed or renamed, and a 0.13.1 file is a valid 0.14.0 file. What changes is what it does: diff --git a/src/app.rs b/src/app.rs index 2f3a5fa..13399c3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -257,7 +257,8 @@ pub struct App { /// Whether the turn now running is a *contained* turn. /// /// Set by the driver as a turn starts and cleared as it ends. It exists for - /// one sentence: `Ctrl+C` stops a steered turn at the next step boundary and + /// one sentence: `Ctrl+C` stops an uncontained turn at the next step boundary + /// and /// a contained one at the next boundary where no child is in flight, and an /// interface that promised the first while doing the second would look as /// though it had swallowed the key. @@ -1104,13 +1105,26 @@ impl App { } self.status.steps = Some(*steps); } + // **A fold is still the better answer at the one moment it happens.** + // `Compacted` reports the section's new size the instant it shrinks, + // before any step has assembled against it — so this arm survives the + // trace read that now fills the same field at every step, and the two + // cannot disagree: `after_tokens` and `ContextEvent::est_tokens` are + // both the assembler's estimate of the observation section, and this + // one is simply earlier. + // + // What is gone is the denominator this arm used to build for itself. + // It asked `ContextBudget::default().effective_tokens(None)` — a flat + // `24_000` on every session in existence — under a comment claiming + // the denominator was "io-harness's own declared budget, asked of the + // harness rather than copied here". It was the *crate's* default + // budget, which is a different number from *this contract's* the + // moment an operator writes a `[run.context]` table, and it was wrong + // for them in silence from the release the field was added in. + // `Status::note_context` divides by `Status::budgets`, which the + // driver fills from the contract it actually built. io_harness::EventKind::Compacted { after_tokens, .. } => { - // The denominator is io-harness's own declared budget, asked of the - // harness rather than copied here — a `24_000` written into this - // file would be wrong after some harness patch, and wrong silently. - let budget = io_harness::ContextBudget::default().effective_tokens(None); - let share = (*after_tokens as f64 / budget.max(1) as f64 * 100.0).round(); - self.status.context = Some(share.clamp(0.0, 100.0) as u8); + self.status.note_context(*after_tokens); } // The draw is per step and the ceiling is the tree's. `tokens` // accumulates because a field that swings rather than climbs cannot @@ -1796,11 +1810,14 @@ impl App { // left over `COMPOSER_ROWS`, because a prompt nobody can see is a // worse trade than a queue nobody can see. // - // At the viewport height a running turn holds, that leaves nothing: - // `composer_rows` is exactly `COMPOSER_ROWS` there, so this draws - // nothing until the composer's allowance is bigger than its floor. - // That is F2's "on a terminal tall enough to hold them" in one line - // of arithmetic rather than a height compared against a number. + // At the viewport height a running turn holds that leaves exactly one + // row, and only because the blank above the activity line is released + // while the queue is open — see `air_rows`. Without it `composer_rows` + // is exactly `COMPOSER_ROWS` at eight rows and this would draw nothing + // on every real session. Below eight there is no blank to release and + // it draws nothing, which is F2's "on a terminal tall enough to hold + // them" in one line of arithmetic rather than a height compared + // against a number. let spare = composer.height.saturating_sub(COMPOSER_ROWS); let want = u16::try_from(self.prompts.len()).unwrap_or(u16::MAX); let queue_rows = if self.queue_open() { diff --git a/src/approval.rs b/src/approval.rs index 347dbc6..11d99db 100644 --- a/src/approval.rs +++ b/src/approval.rs @@ -104,7 +104,8 @@ impl Ask { } } -/// The approver handed to `Session::turn_steered`. +/// The approver handed to `Session::turn_bounded_steered` and to +/// `Session::turn_contained_bounded_steered`. pub struct Asker { asks: mpsc::UnboundedSender, } @@ -515,8 +516,9 @@ impl Approval { ])]; // Past the cell's own header, which the row above replaces, and without - // the blank line it ends with: an overlay four rows tall cannot spend one - // on breathing. Every line is fitted, because this viewport clips. + // the blank line it ends with: an overlay bounded by the viewport cannot + // spend one on breathing. Every line is fitted, because this viewport + // clips. lines.extend( crate::diff::cell(edit, theme, u16::try_from(width).unwrap_or(u16::MAX)) .into_iter() @@ -554,8 +556,8 @@ impl Approval { /// `› allow once · allow this session · deny`, on one row. /// - /// One row rather than a list, because the viewport is four rows and the facts - /// above have first claim on them. The marker is the same `›` every other + /// One row rather than a list, because the viewport does not grow and the + /// facts above have first claim on what it has. The marker is the same `›` every other /// selection surface uses, so the answer that `Enter` would take is marked by /// more than a colour. /// diff --git a/src/commands.rs b/src/commands.rs index 42a3ed6..fce6e81 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -29,7 +29,7 @@ //! a promise the turn might not keep. //! //! **Everything that shows more of something commits upward.** The viewport is -//! four rows and cannot grow, so `/expand` and `Ctrl+T` do not open a pane — they +//! eight rows and cannot grow, so `/expand` and `Ctrl+T` do not open a pane — they //! write into the scrollback, where the terminal's own search, selection and //! copy-mode already work. That is one answer to "show me more" rather than //! three, and it is the same answer the transcript gives. @@ -679,7 +679,7 @@ pub enum Action { /// /// **Into the scrollback and never into a pane**, which is the same answer /// [`Action::Expand`] and [`Action::Transcript`] give to "show me more": the - /// viewport is four rows and cannot grow, and the terminal's own search, + /// viewport is eight rows and cannot grow, and the terminal's own search, /// selection and copy-mode already work on everything committed above it. /// Every field of it is a value io-harness supplied — the policy layers, the /// backend that actually answered, the draw against the tree's ceiling, the @@ -742,9 +742,9 @@ pub enum Action { Image(Option), /// Run later turns contained, stop doing so, or say which it is now. /// - /// `None` is a question and never a toggle: the two modes differ in what a - /// turn can do — fan out, or be steered — and a switch that guessed which - /// one the operator meant would be wrong half the time. + /// `None` is a question and never a toggle: the two modes differ in whether + /// a turn can fan out — steering is on both since 0.17.0 — and a switch that + /// guessed which one the operator meant would be wrong half the time. Contain(Option), /// Make later turns propose a plan before they work, stop doing so, or say /// which it is now. @@ -900,7 +900,7 @@ pub fn parse(input: &str, keys: &Keys, theme: &Theme) -> Action { } // `on` / `off` / nothing. Nothing REPORTS rather than toggles, because // this switch changes what a turn is — a blind toggle would be a coin - // flip between a turn that can be steered and one that can fan out. + // flip between a turn that can fan out and one that does the work itself. "contain" | "containment" => match input.split_whitespace().nth(1) { Some("on") | Some("yes") => Action::Contain(Some(true)), Some("off") | Some("no") => Action::Contain(Some(false)), diff --git a/src/context.rs b/src/context.rs index e365404..e130515 100644 --- a/src/context.rs +++ b/src/context.rs @@ -402,7 +402,7 @@ pub fn window(contract: &TaskContract, remaining: Option) -> u64 { /// The page, committed into the scrollback. /// -/// A page and never a modal: the viewport is four rows and cannot grow, and the +/// A page and never a modal: the viewport does not grow, and the /// terminal's own search, selection and copy already work on everything above it /// — the same answer `/status` and `/expand` give. `seen` is `None` before the /// first turn, and that is said in a sentence rather than drawn as an empty diff --git a/src/contract.rs b/src/contract.rs index c843bdf..81e1bcf 100644 --- a/src/contract.rs +++ b/src/contract.rs @@ -18,6 +18,16 @@ //! carries no longer depends on whether it can fan out; //! `tests/contract.rs`'s F6 is what keeps that true. //! +//! **0.17.0 — the arms moved again, and nothing here moved with them.** Both are +//! now `Session::turn_bounded_steered` and +//! `Session::turn_contained_bounded_steered`, which are the same two calls with a +//! `SteerInbox` appended, so a contained turn can be steered and containment +//! decides fan-out and nothing else. The inbox is a parameter of the drive call +//! rather than a field of [`io_harness::TaskContract`] — it carries the +//! operator's words for the duration of one turn, and a contract is what that +//! turn is *for* — which is the whole reason this module needed no change to get +//! it. `tests/steer.rs`'s F5 asserts both call sites. +//! //! The two seams that are not capabilities of the operator's configuration are //! arguments instead: the responder is unconditional, because io-harness resolves //! it inside the tool dispatch on any run, and the plan gate is present only when diff --git a/src/events.rs b/src/events.rs index 9898150..014922a 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1368,8 +1368,9 @@ impl Events { // the copy here is the poorer one, carrying the word and nothing // else where the dial itself carries the host, the port and the // verdict. `gate_phase_failed` and `gate_output` are the other two: - // they belong to an execution gate, and a steerable turn is built on - // `TaskContract::workspace`, which carries `Verification::None`. So + // they belong to an execution gate, and every contract this crate + // builds — see `crate::contract` — leaves `Verification::None` on it, + // because nothing here ever calls `with_verification`. So // neither can arrive in a session before 0.24.0 gives one a gate, // and neither is given a sentence written in advance of the release // that could check it. @@ -1746,8 +1747,9 @@ fn format_millis(duration: Duration) -> String { /// The vocabulary is io-harness's, and the distinction that matters here is /// between `success` and `finished`: `success` means a verification criterion /// passed, and `finished` means a run with no criterion ended on its own terms. -/// **Every io-cli turn ends `finished`**, because a steerable turn is built on -/// `TaskContract::workspace`, which carries `Verification::None`. +/// **Every io-cli turn ends `finished`**, because every contract this crate +/// builds — see [`crate::contract`] — leaves `Verification::None` on it: nothing +/// here calls `with_verification`, on either arm. /// /// Found in a live run, not in the suite: treating anything that was not /// `success` as a warning meant every ordinary, completely successful turn ended diff --git a/src/exec.rs b/src/exec.rs index be220aa..cea4d12 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -18,6 +18,14 @@ //! the observer rather than through a steer inbox. 0.14.0 deletes what was left //! of the asymmetry: [`contract`] and `contract::session` are both built from //! [`crate::contract::configured`], so what the file says reaches either arm. +//! +//! **And the trade that started it is gone entirely.** Since io-harness 0.67.0 a +//! session turn takes a caller's contract *and* a `SteerInbox` on one call, so +//! nothing is given up for either — an interactive turn is driven through +//! `Session::turn_bounded_steered` or `Session::turn_contained_bounded_steered` +//! and can be spoken to while it runs. This module still takes +//! `turn_bounded_observed`: there is no operator at a keyboard to say anything, +//! and an inbox nobody can write to is a parameter with no sender. use std::io::Write; use std::sync::Mutex; diff --git a/src/fleet.rs b/src/fleet.rs index 77071fc..6559b0c 100644 --- a/src/fleet.rs +++ b/src/fleet.rs @@ -270,7 +270,7 @@ impl Fleet { /// /// **It takes the composer's rows rather than the whole viewport**, so the /// status line — where the spend is — stays on screen underneath it, and the - /// streaming tail above it goes on moving. The viewport is four rows and + /// streaming tail above it goes on moving. The viewport is eight rows and /// cannot grow: a taller view would mean tearing down the `Screen` and /// rebuilding it, which is what the wizard does before a session starts and /// is not something to do while a run is committing into scrollback. diff --git a/src/lib.rs b/src/lib.rs index c4dab9d..679a0f2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,7 +20,11 @@ //! with the fan-out or not at all. 0.11.0 moved the flat turn onto //! `Session::turn_bounded_observed`, which takes a contract too, and 0.12.0 //! finished the separation: every turn can answer a question, and a plan is -//! proposed only where the operator typed `/plan on`. +//! proposed only where the operator typed `/plan on`. 0.17.0 took the last thing +//! off that switch — both arms are now `Session::turn_bounded_steered` and +//! `Session::turn_contained_bounded_steered`, so a contained turn can be steered +//! mid-flight exactly as an uncontained one can, and containment decides fan-out +//! and nothing else. See [`queue`] for the surface an operator says it through. //! //! [io-harness]: https://docs.rs/io-harness diff --git a/src/main.rs b/src/main.rs index e78483d..2daaa37 100644 --- a/src/main.rs +++ b/src/main.rs @@ -290,7 +290,7 @@ async fn drive( notices.push(complaint); } // The caps the fleet needs, read once and cloned out of the settings. A - // session with none cannot fan out: `turn_contained_bounded_observed` is the only + // session with none cannot fan out: `turn_contained_bounded_steered` is the only // entry point that reaches io-harness's spawn loop, and it is the caps that // decide whether this session takes it. let containment = settings::containment(stored.as_ref()).cloned(); @@ -1279,11 +1279,13 @@ async fn loop_over Result>( let where_it_is = if contained { settings::contained_notice(caps, app.theme.glyphs.dash) } else { - // **Not "steered".** Neither turn takes a `SteerInbox` - // since 0.11.0 — the flat arm gave its up for a - // contract and the contained arm never had one — so a - // word promising mid-turn redirection describes - // nothing this product does. + // **Not "steered", and since 0.17.0 for the opposite + // reason.** Both arms hold a `SteerInbox` now, so + // steering is what this turn and a contained one have + // in common — naming it here would offer as a + // consolation something the other mode has too. The + // one difference either way is the fan-out, so that + // is the only thing this sentence names. "not contained — this turn does the work itself and cannot fan out" .to_string() }; @@ -1393,7 +1395,7 @@ async fn loop_over Result>( screen.commit(&lines).map_err(|error| error.to_string())?; } // **Committed upward, exactly as `/expand` and `Ctrl+T` are.** - // The viewport is four rows and cannot grow, so everything that + // The viewport is eight rows and cannot grow, so everything that // shows more of something writes into the terminal's own // scrollback — one answer to "show me more" rather than three. // @@ -1613,7 +1615,8 @@ async fn loop_over Result>( // The caps reach the turn only while the session is in // contained mode, so `/contain off` is a real switch and // not a label: with `None` here the turn built below is - // the steered turn, byte for byte. + // the uncontained turn, byte for byte. Both arms are + // steered, so that is not the word for the difference. contained.then_some(containment.as_ref()).flatten(), &capabilities, planning, @@ -1818,8 +1821,9 @@ async fn turn( ) -> Result { let (observer, mut events) = bridge::channel(); // The one way a turn is stopped from the interface, contained or not. Both - // arms take a contract now and neither takes a steer inbox, so `Flow::Cancel` - // out of `Bridge::event` is what `Ctrl+C` and `Esc` set. + // arms take a contract **and** a steer inbox since 0.17.0, and the stop key + // stayed here rather than moving onto `Steer::interrupt`: `Flow::Cancel` out + // of `Bridge::event` is what `Ctrl+C` and `Esc` set, on either arm. let canceller = observer.canceller(); // The other seam, and the one that can stop the agent. `DenyAll` stood here // through 0.1.0 and 0.1.1, which is why the *ask before writes* posture @@ -1880,8 +1884,7 @@ async fn turn( // loop and `running` holds `&mut session` for the whole of it. let root = session.root().to_path_buf(); app.contained = containment.is_some(); - // Built before the future borrows it, and only for the arm that can take one: - // the flat turn is handed `text` itself, exactly as it always was. + // Built before the future borrows it, and for both arms alike. // **Every turn carries one now, contained or not.** Through 0.11.0 the flat // arm was `turn_steered`, which builds `TaskContract::workspace` inside // io-harness and takes none from the caller — so its step cap was twelve, @@ -1979,6 +1982,11 @@ async fn turn( app.status.elapsed = at; app.event(&event, at); commit_edits(app, store, &event, screen.width()); + // The live half of `ctx N%`. Anchored on a step rather than on + // every event, for the reason `commit_edits` above it is: the + // assembly is written once a step and reading it per event would + // be one store query per token. + app.status.note_context_from(store, &event); commit_viewed(screen, app, &root, policy, &event)?; commit_fold(app, store, &event, &mut folding); paint(screen, app)?; @@ -2299,6 +2307,10 @@ async fn turn( // and the last step of a turn is exactly the one that loses that race, // so the edit a reader most wants to see is the one that vanishes. commit_edits(app, store, &event, width); + // And on the drain, for the same race the two lines above it are here + // for: the last step of a turn is exactly the one whose event the select + // loop loses to the turn's own return. + app.status.note_context_from(store, &event); // And the picture, for the same reason and the same race: a `view_image` // on the turn's last step is exactly the one the drain would otherwise // lose. @@ -2416,7 +2428,7 @@ fn commit_edits(app: &mut App, store: &Store, event: &io_harness::RunEvent, widt /// Put the whole conversation back into the terminal's own scrollback. /// -/// Upward and never into a pane. The viewport is four rows and cannot grow, and +/// Upward and never into a pane. The viewport is eight rows and cannot grow, and /// there is no alternate screen in this product — so the place a reader reads /// something long is the terminal's own buffer, where its search, its selection /// and tmux copy-mode already work. A failure to read the store says so and diff --git a/src/picker.rs b/src/picker.rs index 6da3d9f..58072bd 100644 --- a/src/picker.rs +++ b/src/picker.rs @@ -448,7 +448,9 @@ impl Picker { // which is a property of one caller and not of the widget. let width = area.width as usize; // **The query is drawn in place of the title, never above it.** The - // in-session viewport is four rows and is fixed at attach, so a query line + // in-session viewport is `term::VIEWPORT_HEIGHT` and is fixed at attach — + // four rows when this was written and eight since 0.13.0, of which the + // status footer, the rule and the activity line take most — so a query line // of its own would leave `/resume` two visible rows — and the note on // `term::WIZARD_VIEWPORT_HEIGHT` already records that three was the count // a live first run found unusable, which is why that constant exists. The diff --git a/src/queue.rs b/src/queue.rs index de476ab..ef557b5 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -52,6 +52,18 @@ //! it was typed against off the screen — and the queue is at its longest exactly //! when the turn under it is at its most worth reading. //! +//! **Where the spare row comes from, and why there is one.** The composer's +//! allowance at [`crate::term::VIEWPORT_HEIGHT`] — the eight rows a running turn +//! actually holds — is exactly `COMPOSER_ROWS`, so a surface taking only what is +//! left over that floor would draw nothing on every real session. So while the +//! queue is open the layout releases the **blank row above the activity line** +//! into the composer's allowance, and takes it back the moment the queue closes: +//! see `air_rows` in [`crate::app::App::render`] for the argument, and +//! `n2_the_surface_is_visible_at_the_running_viewport_and_costs_it_no_height` in +//! `tests/queue_surface.rs` for the assertion. The blank carries nothing and the +//! queue carries something; the frame is the same height either way, which is +//! what "never grows the viewport" was always about. +//! //! **It never hides the prompt.** This is the one place it parts company with //! the fleet view, which takes the whole composer rect. That view is opened by a //! key, so an operator who cannot see the prompt knows why and closes it; this @@ -60,9 +72,9 @@ //! the person who caused it. So it draws above the composer or it does not draw //! at all. //! -//! What falls out of the last two together is the honest shape of a short -//! viewport: when the composer's allowance is a single row there is nothing to -//! spare, and this draws nothing. The notice +//! What falls out of the last two together is the honest shape of a terminal +//! too short even for the blank: below eight rows there is no row to release, +//! nothing is spare over `COMPOSER_ROWS`, and this draws nothing. The notice //! [`crate::app::App::queue_prompt`] already wrote is what says the line was //! kept — a surface is what keeps it visible *afterwards*, and afterwards is //! worth a row only when there is one to give. diff --git a/src/settings.rs b/src/settings.rs index 54bee5b..3dba157 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -63,7 +63,7 @@ pub struct CliSettings { /// The caps a fan-out runs under: `[app.io-cli.containment]`. /// /// **This key is what turns the fleet on, and it is not a preference.** - /// `Session::turn_contained_bounded_observed` is the only session entry point + /// `Session::turn_contained_bounded_steered` is the only session entry point /// that passes a containment into the driver, and therefore the only one that /// reaches the loop owning the spawn tool — so a session with no caps /// configured cannot decompose anything, and one with them runs a materially @@ -143,11 +143,13 @@ pub fn containment(stored: Option<&CliSettings>) -> Option<&io_harness::Containm /// wrong. It offered a responder, a plan gate, MCP servers, language servers, a /// browser and skills as things this mode grants, and named a lost mid-turn steer /// as the price. Both stopped being true: 0.11.0 gave the flat turn a contract -/// too, so every one of those capabilities is on both turns, and neither turn -/// takes a `SteerInbox` any more — `Ctrl+C` is the observer's cancel on both. +/// too, so every one of those capabilities is on both turns, and since 0.17.0 +/// both turns take a `SteerInbox` as well — a contained turn can be steered, so +/// there is no price to name. `Ctrl+C` is the observer's cancel on both, which +/// is the half of that old sentence that was right all along. /// /// What is left is one difference, and it is the one the caps are for: -/// `turn_contained_bounded_observed` is the only session entry point that reaches +/// `turn_contained_bounded_steered` is the only session entry point that reaches /// io-harness's spawn loop, so this is the only turn that can fan out. A notice /// that sold the mode on anything else was talking an operator into a fan-out to /// get capabilities their session already had. @@ -427,7 +429,8 @@ pub fn render( // Left out with the most force of all: this key changes what a // turn *is*, not how it looks. The wizard asks nothing about // fan-out, and a file that arrived with caps already in it would - // have turned steering off for somebody who never chose to. + // have put every turn through io-harness's spawn loop for + // somebody who never chose to. containment: None, // The capability keys are left out because the wizard asks about // none of them and a file that arrived with an MCP server, a diff --git a/src/status.rs b/src/status.rs index bcc950b..b0bde0b 100644 --- a/src/status.rs +++ b/src/status.rs @@ -120,6 +120,16 @@ impl Field { /// reading `left 997/1000 steps` on every session would be reporting io-cli's /// scaffolding back to the operator as a budget they set, and 0.14.0's F6 is /// explicit that a session with no `[run]` table shows no budget field at all. +/// +/// **A fourth field since 0.17.0, and it is the one that draws no row.** +/// [`Budgets::window`] is the context window a turn's prompt is assembled inside. +/// It is here rather than loose on [`Status`] because it arrives from exactly the +/// same place at exactly the same moment as the other three — off the contract +/// the driver has just built, in the one assignment that already reads it, so +/// there is no second driver line that could be forgotten — and because it is a +/// session fact for the same reason they are: the file does not change while a +/// session runs. It is an `Option` too, but for a different question; its own doc +/// says which, and why it draws nothing. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct Budgets { /// `[run] max_steps`, or `[app.io-cli] max_steps` where that beat it — and @@ -129,6 +139,33 @@ pub struct Budgets { pub tokens: Option, /// `[run] max_duration_secs`, as wall time across the turn. pub duration: Option, + /// The context window one turn's prompt is assembled inside, in tokens: + /// `[run.context]`, taking its `share` of `[run] max_tokens` where the + /// operator set one. + /// + /// **An `Option` for a different reason from its three neighbours, and the + /// difference is the point.** Those are `Option` because the ceiling itself + /// may not exist — a turn with no time budget is not a turn budgeted at zero. + /// A context window always exists: `TaskContract::context` is a plain + /// `ContextBudget` and not an `Option` of one, so every contract that exists + /// declares a window, io-harness's own `24_000` where nobody chose otherwise. + /// `None` here therefore does not mean *no window*. It means **io-cli has not + /// been handed a contract yet** — the state [`Budgets::default`] carries for + /// the moments before the first turn is built. + /// + /// A sentinel `0` would have been the alternative and would have been worse in + /// exactly this product's characteristic way: a number that divides is a + /// number somebody eventually divides by. [`Status::note_context`] says + /// nothing at all on `None`, so a driver that forgot to fill this in loses the + /// `ctx` field outright rather than quietly reporting a share of a window + /// nobody set — which is precisely how this field spent two releases wrong. + /// + /// **It draws no row in [`Status::budgets_left_of`], unlike its three + /// neighbours.** Those are remainders an operator watches drain; this one is a + /// denominator, and it is already on the line — `ctx N%` is the assembled + /// section over exactly this number. A `left 18k/24k ctx` beside it would be + /// the same fact twice, in two spellings that round differently. + pub window: Option, } impl Budgets { @@ -148,6 +185,21 @@ impl Budgets { steps: (contract.max_steps != crate::contract::MAX_STEPS).then_some(contract.max_steps), tokens: contract.max_tokens, duration: contract.max_duration, + // **The harness's arithmetic, asked of the harness — which is what + // the arm this replaces only claimed to be doing.** Through 0.16.0 + // the share on the line was taken against + // `ContextBudget::default().effective_tokens(None)`, a constant + // `24_000` on every session in the world, under a comment asserting + // it was io-harness's own declared budget. It was the *crate's* + // default budget; this is *this contract's*, which is a different + // number the moment an operator writes a `[run.context]` table. + // + // `max_tokens` is passed and not `None`, because `ContextBudget` + // takes its `share` of what the run's own token budget leaves: a + // window computed as if there were no run budget is not the window + // the assembler had. The expression is io-harness's own — `session.rs` + // bounds `entry_cap_chars` with exactly this pair. + window: Some(contract.context.effective_tokens(contract.max_tokens)), } } } @@ -222,16 +274,37 @@ pub struct Status { /// /// Cleared by `Status::start_run`, which is the one place a run begins. pub run_tokens: Option, - /// How full the assembled context was the last time io-harness said so, as a - /// share of the budget io-harness itself declares. + /// How full the assembled context was at the last step, as a share of the + /// window *this session's contract* declares — [`Budgets::window`]. /// - /// `None` until a fold reports one, which is the honest answer: `Compacted` is - /// the only event carrying an observation-section size, and between folds - /// nothing on the event stream knows it. + /// **0.16.0's version of this field was wrong from the release it was added + /// in, and it was wrong silently.** It divided by + /// `ContextBudget::default().effective_tokens(None)` — a flat `24_000` — so an + /// operator who set `[run.context] max_tokens = 8000` was shown a share of a + /// window three times the one they had, and nothing on the screen could + /// disagree with it. Both halves of the fix are elsewhere: + /// [`Budgets::in_force`] is the denominator, and [`Status::note_context_from`] + /// is the numerator. /// - /// ponytail: derived from the last fold. The per-step estimate is durable in - /// the harness store as `ContextEvent::est_tokens`, so a live share is one - /// store read away if this field turns out too quiet to be useful. + /// **And it was blank for exactly the period it would have been useful.** The + /// only source was `EventKind::Compacted`, which io-harness emits when a fold + /// happens and never otherwise — so a session whose context never filled up + /// showed no `ctx` field at all, and the first number an operator ever saw was + /// one taken after the section had just been cut down. The per-step estimate + /// was durable in the harness store the whole time, which is the ponytail note + /// that stood here for two releases and is now cashed in. + /// + /// `None` before the first step of the first turn, which stays the honest + /// answer: nothing has been assembled, and a session that has assembled + /// nothing has not assembled zero tokens — the rule `tokens` and `spend` are + /// held to. The *window* is known from the moment a contract exists, and + /// [`committed`] says it there rather than leaving the page blank. + /// + /// Cleared by [`Status::forget_run`] and deliberately still so. The share is + /// an observation the undone run made about its own ledger; a rewind or a + /// `/resume` puts a different conversation on screen, whose section this + /// number never measured. The window beside it is not cleared, because the + /// file did not change. pub context: Option, /// How this run's commands are contained: the mode asked for and the backend /// that actually answered on this host. @@ -490,6 +563,90 @@ impl Status { self.run_tokens = None; } + /// Set [`Status::context`] from an observation section of `est_tokens`. + /// + /// **The denominator is [`Budgets::window`] and never a budget built here, and + /// that is the whole of F10.** Everything about how the window is arrived at — + /// io-harness's default, a `[run.context]` table, the `share` it takes of + /// `[run] max_tokens` — is already resolved by the time a `TaskContract` + /// exists, and `Budgets::in_force` reads it off that contract. A second + /// resolution here would be a second answer to a settled question, and the two + /// would disagree the first time a layer moved. That is not a hypothetical: + /// the arm this replaces resolved it a second time, got `24_000`, and said so + /// under a comment claiming it had asked the harness. + /// + /// With no window — no contract has reached this `Status` yet, see + /// [`Budgets::window`] — the field is left exactly as it was. Losing the `ctx` + /// field is the loud failure; a share of an invented denominator is the quiet + /// one, and quiet is what this whole method exists to end. + /// + /// Clamped to `100` because an estimate can exceed the ceiling it is measured + /// against: `ContextBudget` bounds what the assembler *aims* for and the fold + /// is what enforces it, so a section briefly over its window is ordinary. + /// `ctx 137%` would read as a bug in this line rather than as the pressure it + /// actually is. + pub fn note_context(&mut self, est_tokens: u64) { + let Some(window) = self.budgets.window.filter(|window| *window > 0) else { + return; + }; + let share = (est_tokens as f64 / window as f64 * 100.0).round(); + self.context = Some(share.clamp(0.0, 100.0) as u8); + } + + /// The same share, taken from the durable trace once a step has landed. + /// + /// **This is the numerator, and the store is the only place it exists.** + /// io-harness records `ContextEvent::assembled` — the assembler's own estimate + /// for the section it has just built — as one row per step, and emits no event + /// carrying that number. `EventKind::Compacted` says how big the section is + /// only on the steps where a fold happened, and `PromptComposed` counts the + /// *system* block in bytes, which is a different section measured in a + /// different unit. So a share that reads the event stream alone is blank until + /// a fold, and no arithmetic anywhere could have fixed that. + /// + /// `est_tokens` and not `reported_tokens`, though the row carries both. + /// `reported_tokens` is what the provider billed for the whole request — + /// system block, tools, transcript and all — while `est_tokens` is the + /// observation section alone, which is the thing `ContextBudget` bounds and + /// the thing `Compacted::after_tokens` reports. `ctx N%` has to mean one + /// quantity, and mixing the two would make the number jump at every step + /// boundary for no reason a reader could see. + /// + /// **Anchored on `Step`, for the reason `main.rs`'s `commit_edits` is.** + /// io-harness documents `Step` as emitted once the step has been committed to + /// the store, so the row is there to be read; a read at `ToolCall` is a read + /// of a row that may not exist yet, and the two events are one line apart in a + /// transcript, which is what would keep that invisible until it was a bug. + /// One read per step, not one per event. + /// + /// ponytail: `Store::context_events` returns the whole run each call, so a run + /// of `n` steps reads `n(n+1)/2` rows across its life — five-column rows + /// behind an indexed `run_id`, and a turn is tens of steps rather than + /// thousands. The upgrade, if that ever stops being true, is a `LIMIT 1` + /// accessor in the harness and not a copy of the number kept here, which could + /// disagree with the trace it was taken from. + /// + /// A read that fails leaves the field as it was and says nothing. This is a + /// decoration on a status line: a run whose work succeeded is not one to + /// interrupt because a trace could not be re-read, and unlike a diff there is + /// nothing here worth spending a scrollback row to apologise for. + pub fn note_context_from(&mut self, store: &io_harness::Store, event: &io_harness::RunEvent) { + let io_harness::EventKind::Step { .. } = &event.kind else { + return; + }; + let Ok(events) = store.context_events(event.run_id) else { + return; + }; + if let Some(est) = events + .iter() + .rev() + .find(|recorded| recorded.kind == "assembled") + .and_then(|recorded| recorded.est_tokens) + { + self.note_context(est); + } + } + /// The indicator, if there is anything to indicate and anywhere to show it. /// /// `None` under `NO_COLOR`, where an animation is noise a reader cannot use — @@ -1359,11 +1516,34 @@ pub fn committed( facts.push(("budget".into(), text)); } + // **The window comes off the contract handed in, exactly as the budgets above + // do, and for the same reason**: this page reports what the *next* turn would + // run under, and reading it changes nothing. The share beside it is still the + // session's own — what has been assembled is a fact about the turns that ran, + // however the ceiling was arrived at — which is the split + // `budgets_left_of` is built on. + // + // The size is said in the empty case too. `not known until the context has + // been folded once` was the old text, and it was a true description of a + // defect: the number arrived only at a fold. Now the *window* is known from + // the moment a contract exists, so a page that said nothing at all would be + // withholding the one half it can always answer — and it is the half an + // operator checking whether their `[run.context]` table took effect is + // actually looking for. + // + // `Budgets::in_force` always fills the window: it has a contract in hand and + // every contract declares one. The arm below is that type's `None` spelled out + // rather than an `unwrap` — a committed page is not worth a panic — and it is + // unreachable from here. + let window = match Budgets::in_force(contract).window { + Some(tokens) => format_tokens(tokens), + None => "unknown".to_string(), + }; facts.push(( "context".into(), match status.context { - Some(fill) => format!("{fill}% of the budget io-harness declares"), - None => "not known until the context has been folded once".to_string(), + Some(fill) => format!("{fill}% of a {window} window"), + None => format!("nothing assembled yet {dash} the window is {window}"), }, )); diff --git a/src/term.rs b/src/term.rs index 9faf319..31c270f 100644 --- a/src/term.rs +++ b/src/term.rs @@ -83,7 +83,7 @@ use ratatui::{Frame, Terminal, TerminalOptions, Viewport}; /// The ceiling that buys: a prompt longer than two rows scrolls within them /// rather than expanding the viewport. 0.7.0 spends that ceiling rather than /// raising it — the palette and path completion are pickers drawn into these -/// same four rows, a picker's query is drawn in place of its title so it costs +/// same rows, a picker's query is drawn in place of its title so it costs /// no row, and a paste too big for two rows becomes one line naming itself /// instead of a prompt that has to grow. pub const VIEWPORT_HEIGHT: u16 = 8; diff --git a/tests/approval.rs b/tests/approval.rs index b615a71..229c6f0 100644 --- a/tests/approval.rs +++ b/tests/approval.rs @@ -259,7 +259,7 @@ async fn f2_an_unnamed_action_says_the_tier_default_decided() { /// /// **Restated in 0.3.0.** Until then the overlay showed the proposed content as /// plain lines, so this asserted the first of them was on screen. Now a write -/// whose target can be read is shown as a change, and at a session's four rows +/// whose target can be read is shown as a change, and at a session's few rows /// the one row available carries `+40 -855` rather than the first line of the /// file — which is the more useful row, and the one this test now pins. What has /// not changed is the part that mattered: whatever is not shown is counted out diff --git a/tests/contain.rs b/tests/contain.rs index aed97c2..4b78383 100644 --- a/tests/contain.rs +++ b/tests/contain.rs @@ -3,10 +3,12 @@ //! F7 — `Ctrl+C` ends a contained turn through the observer, and says so. //! //! The mode is a property of the *turn*, not of the interface, and that is what -//! these assert. io-harness offers no session entry point that takes a caller's -//! containment and a steer inbox together, so a session either fans out or is -//! steerable; every claim below is about which of those this session is in and -//! what it told the operator about it. +//! these assert. Since 0.17.0 the switch decides the fan-out and nothing else: +//! `turn_contained_bounded_steered` and `turn_bounded_steered` both take a +//! caller's containment-or-not, a contract and a `SteerInbox`, so a contained +//! turn can be steered exactly as an ordinary one can. Every claim below is about +//! which of the two turns this session takes and what it told the operator about +//! it — never about what it gave up to get there, because it gives up nothing. mod support; @@ -111,9 +113,13 @@ max_total_tokens = 200000 /// reason the mode has a configuration key at all. The negative half is the two /// claims 0.11.0 falsified and this notice went on making for a release: that the /// mode is what grants skills, MCP, language servers and a browser, and that it -/// costs a mid-turn steer. Both turns carry a contract now and neither can be -/// steered, so the first was selling capabilities the session already had and the -/// second was charging for something nobody still has. +/// costs a mid-turn steer. Both turns carry a contract since 0.11.0 and both +/// carry a `SteerInbox` since 0.17.0, so the first was selling capabilities the +/// session already had and the second was charging for something a contained turn +/// keeps. The 0.11.0 phrase about steering stays in the absent list below for +/// that reason — it was wrong when it was charged for, and it is wrong twice over +/// now — and it is written there as a literal rather than here in prose, so that +/// `tests/docs.rs`'s comment sweep reads it as the assertion it is. /// /// Sabotage: restore the 0.11.0 wording — under which only this test fails, and /// it fails on the absences rather than the caps, which is the half that decides @@ -174,11 +180,11 @@ fn f1_contain_parses_as_a_question_or_an_answer() { ); } -/// F7 — a steered turn is never cancelled through the observer. +/// F7 — no turn is cancelled through the observer until the flag is set. /// /// The negative half, and the one the sabotage arm attacks: a bridge that -/// answered `Cancel` because a turn was running would cancel every steered turn -/// the moment its first event arrived. +/// answered `Cancel` because a turn was running would cancel every turn, contained +/// or not, the moment its first event arrived. #[test] fn f7_the_bridge_continues_until_it_is_told_to_cancel() { let (observer, mut events) = bridge::channel(); @@ -212,10 +218,13 @@ fn f7_the_interrupt_says_where_the_turn_will_stop() { // undone now and says nothing at all — see `App::undoable`. app.status.steps = Some(1); assert_eq!(app.key(ctrl('c')), Command::Interrupt); - let steered = said(&mut app); + // Both kinds of turn are steered since 0.17.0, so the word for this one is + // *uncontained* — the sentence differs on where it can stop, not on whether + // the operator can speak to it. + let uncontained = said(&mut app); assert!( - steered.contains("next step"), - "a steered turn stops at a step boundary: {steered}" + uncontained.contains("next step"), + "an uncontained turn stops at a step boundary: {uncontained}" ); let mut app = App::new(DARK, "a-model"); diff --git a/tests/context_share.rs b/tests/context_share.rs new file mode 100644 index 0000000..6629ada --- /dev/null +++ b/tests/context_share.rs @@ -0,0 +1,540 @@ +//! F10 — `ctx N%` is true for an operator who configured their window. +//! +//! **The field this covers was wrong from the release it was added in, and it was +//! wrong in the way that costs the most: silently.** Through 0.16.0 the status +//! line divided the assembled section by +//! `ContextBudget::default().effective_tokens(None)` — a flat `24_000`, identical +//! on every session anybody has ever run — under a comment in `src/app.rs` +//! asserting that "the denominator is io-harness's own declared budget, asked of +//! the harness rather than copied here". It was the *crate's* default budget. An +//! operator who wrote a `[run.context]` table was shown a share of a window they +//! did not have, and nothing on the screen could contradict it: a percentage is +//! plausible at any value. +//! +//! So every assertion below is made against a contract built from **configuration +//! text**, through `io_cli::contract::configured` and `Config::from_toml`, and +//! never against a `TaskContract` this file assembled by hand. A test that set +//! `ContextBudget` on a builder would prove that `effective_tokens` divides, which +//! io-harness already proves; the property here is that an operator's *file* +//! reaches the number on the line. The three windows below are 8k, 20k and 24k and +//! they are deliberately all different, so restoring any one of the two wrong +//! denominators moves a number this file names. +//! +//! The second half of the criterion is when the number arrives. `EventKind:: +//! Compacted` fires when a fold happens and never otherwise, so a session whose +//! context never filled showed no `ctx` field at all — blank for exactly the +//! period in which it would have been worth reading. The numerator now comes off +//! `ContextEvent::assembled`, which io-harness has recorded per step in its own +//! store the whole time. +//! +//! Nothing here reads a clock or sleeps: every fact is a row in an in-memory store +//! or a field on a struct. + +use std::time::Duration; + +use io_cli::app::App; +use io_cli::status::{Budgets, Status}; +use io_cli::theme::DARK; +use io_harness::{Config, ContextBudget, ContextEvent, EventKind, RunEvent, Store, TaskContract}; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +/// A contract as an operator's `io.toml` produces one. +/// +/// **`Config::from_toml` and not a builder call.** The whole feature is that a +/// file reaches the status line, and `[run.context]` is a sub-table of `[run]` +/// rather than a table of its own — `Config::apply_to` returns early on a file +/// with no `[run]` table at all, so a fixture that wrote a top-level `[context]` +/// would be rejected by `deny_unknown_fields` and a fixture that wrote +/// `[run.context]` alone still works, because TOML creates `run` on the way past. +fn from_config(toml: &str) -> TaskContract { + let config = Config::from_toml(toml).expect("the fixture's io.toml parses"); + io_cli::contract::configured( + "summarise the module", + std::path::PathBuf::from("/tmp/io-cli-context-share"), + &config, + ) +} + +/// `[run.context]` set: an 8k ceiling taking a quarter of a 40k run budget. +/// +/// Both keys, because both are wrong in the shipped code — the ceiling was ignored +/// in favour of `24_000`, and the share was computed against `None` instead of +/// against what `[run] max_tokens` leaves. `min(8_000, max(40_000 * 0.25, floor))` +/// is `8_000`: the ceiling wins here, which is what makes this window distinct +/// from the one below. +fn tight() -> TaskContract { + from_config( + "[run]\n\ + max_tokens = 40000\n\ + \n\ + [run.context]\n\ + max_tokens = 8000\n\ + share = 0.25\n", + ) +} + +/// No `[run.context]`, but a `[run] max_tokens` the default budget takes half of. +/// +/// `min(24_000, max(40_000 * 0.5, floor))` is `20_000` — *not* `24_000`. This is +/// the window the second half of the defect throws away by passing `None`, and it +/// is why this fixture and [`uncapped`] must report different shares even though +/// neither one configures a context budget. +fn roomy() -> TaskContract { + from_config("[run]\nmax_tokens = 40000\n") +} + +/// No context budget and no token budget: io-harness's own `24_000`, flat. +/// +/// This is the sabotage's value arrived at legitimately. A session that really +/// does run under the defaults should read `24_000`, and the shipped code read +/// `24_000` for *every* session — so this fixture is the one case the old +/// arithmetic got right, and it exists here to prove the new arithmetic still +/// gets it right rather than over-correcting. +fn uncapped() -> TaskContract { + from_config("[run]\nmax_steps = 20\n") +} + +/// A status line that has been handed a contract, as the driver hands it one. +fn under(contract: &TaskContract) -> Status { + let mut status = Status::new("anthropic/claude-sonnet-4.5"); + status.budgets = Budgets::in_force(contract); + status +} + +/// A store with one run, and the assembly the harness recorded for a step of it. +fn assembled(step: u32, est_tokens: u64) -> (Store, i64) { + let store = Store::memory().expect("an in-memory store"); + let run_id = store + .start_run("summarise the module", "openrouter") + .expect("a run"); + store + .record_context_event( + run_id, + &ContextEvent::assembled(step, "carried=3 stubbed=0 reread=0", est_tokens), + ) + .expect("the trace records an assembly"); + (store, run_id) +} + +/// The event io-harness emits once a step has been committed to the store. +fn step(run_id: i64, step: u32) -> RunEvent { + RunEvent::new( + run_id, + step, + EventKind::Step { + decision: "read the module".into(), + tool_call: "read".into(), + tokens: 900, + changed: false, + }, + ) +} + +/// The one denominator this feature must never use again. +/// +/// Named as a function rather than as `24_000` so that the sabotage is *this +/// expression* — the exact call the shipped code made — and not a constant that +/// happens to equal it today. +fn the_defect() -> u64 { + ContextBudget::default().effective_tokens(None) +} + +// --------------------------------------------------------------------------- +// The denominator +// --------------------------------------------------------------------------- + +/// **The test the sabotage kills.** +/// +/// Restore `ContextBudget::default().effective_tokens(None)` as the denominator +/// and `tight` reports `17%` instead of `50%` here, on a fixture whose file plainly +/// says the window is 8k. Nothing else in the suite moves, which is the shape the +/// criterion names: it fails alone, and in the shipped code it failed silently. +#[test] +fn the_share_is_taken_against_the_configured_window_and_not_the_crate_default() { + let contract = tight(); + assert_eq!( + Budgets::in_force(&contract).window, + Some(8_000), + "`[run.context] max_tokens = 8000` is the window this turn assembles inside", + ); + + let mut status = under(&contract); + status.note_context(4_000); + assert_eq!( + status.context, + Some(50), + "4k of an 8k window is half of it, whatever io-harness's own default is", + ); + + // And the number the defect produced, spelled out, so the diff between them is + // on the record rather than left to arithmetic in a reader's head. + let wrong = (4_000f64 / the_defect() as f64 * 100.0).round() as u8; + assert_eq!(wrong, 17, "the shipped denominator was a flat 24k"); + assert_ne!( + status.context, + Some(wrong), + "a share of the crate default is a share of a window this operator does not have", + ); +} + +/// The other half of the same defect: `None` was passed for the run's allowance, +/// so `[run] max_tokens` never narrowed anything. +/// +/// `roomy` and `uncapped` configure no context budget at all. They differ only in +/// whether the file sets `[run] max_tokens`, and under the shipped code they were +/// therefore indistinguishable — both `24_000`. +#[test] +fn a_run_token_budget_narrows_the_window_by_the_share_the_budget_declares() { + assert_eq!( + Budgets::in_force(&roomy()).window, + Some(20_000), + "the default budget takes its `share` of what `[run] max_tokens` leaves", + ); + assert_eq!( + Budgets::in_force(&uncapped()).window, + Some(the_defect()), + "with no run budget the ceiling is `max_tokens` flat, which is the one case \ + the shipped code got right", + ); + + let mut narrowed = under(&roomy()); + narrowed.note_context(4_000); + let mut wide = under(&uncapped()); + wide.note_context(4_000); + assert_eq!(narrowed.context, Some(20)); + assert_eq!(wide.context, Some(17)); + assert_ne!( + narrowed.context, wide.context, + "passing `None` for the remaining allowance collapses these two files into one", + ); +} + +/// Three files, three windows, three shares of the same section. +/// +/// The point is the spread. A denominator that ignores configuration makes all +/// three of these equal, and one assertion catching that is easier to argue away +/// than a table where every row moves. +#[test] +fn the_same_section_reads_differently_under_three_different_files() { + let shares: Vec> = [tight(), roomy(), uncapped()] + .iter() + .map(|contract| { + let mut status = under(contract); + status.note_context(4_000); + status.context + }) + .collect(); + assert_eq!(shares, vec![Some(50), Some(20), Some(17)], "{shares:?}"); +} + +/// No contract, no claim. +/// +/// `Budgets::default()` is what a `Status` carries before the driver has built a +/// turn, and its window is `None` — *io-cli has not been told*, which is a +/// different thing from *there is no window*. The field is left alone rather than +/// falling back to anything: losing `ctx` from the line is a failure somebody +/// notices, and a share of an invented denominator is the failure this whole +/// release is undoing. +#[test] +fn a_status_that_has_never_seen_a_contract_says_nothing_at_all() { + let mut status = Status::new("anthropic/claude-sonnet-4.5"); + assert_eq!(status.budgets.window, None); + status.note_context(4_000); + assert_eq!( + status.context, None, + "with no window in hand the honest answer is silence, not a default", + ); + assert!( + !status.line(200, &DARK).to_string().contains("ctx"), + "and the line draws nothing for it", + ); +} + +/// A section larger than the window it was assembled against is ordinary — the +/// budget is what the assembler aims at and the fold is what enforces it — so the +/// share saturates rather than reporting `ctx 150%`. +#[test] +fn an_oversized_section_saturates_instead_of_reading_as_a_bug_in_the_line() { + let mut status = under(&tight()); + status.note_context(12_000); + assert_eq!(status.context, Some(100)); +} + +// --------------------------------------------------------------------------- +// The numerator, and when it arrives +// --------------------------------------------------------------------------- + +/// **The half no arithmetic could have fixed.** The field is filled from the first +/// step of the first turn, with no fold anywhere in the run. +#[test] +fn the_share_is_there_from_the_first_step_of_the_first_turn() { + let (store, run_id) = assembled(1, 4_000); + let mut status = under(&tight()); + assert_eq!( + status.context, None, + "before a step has landed there is nothing assembled to report", + ); + + status.note_context_from(&store, &step(run_id, 1)); + + assert_eq!( + status.context, + Some(50), + "one step, no fold, and the share is on the line", + ); + assert!( + status.line(200, &DARK).to_string().contains("ctx 50%"), + "{:?}", + status.line(200, &DARK).to_string(), + ); +} + +/// The trace is read once a step has landed and at no other event. +/// +/// `EventKind::ToolCall` is emitted *before* the result is known, so a read there +/// is a read of a row that may not exist yet — the same anchor `main.rs`'s +/// `commit_edits` documents, and the same reason. +#[test] +fn only_a_committed_step_makes_the_line_read_the_trace() { + let (store, run_id) = assembled(1, 4_000); + let mut status = under(&tight()); + + for kind in [ + EventKind::ToolCall { + name: "read".into(), + target: "src/lib.rs".into(), + }, + EventKind::Stalled, + ] { + status.note_context_from(&store, &RunEvent::new(run_id, 1, kind)); + assert_eq!( + status.context, None, + "only `Step` is documented as emitted after the row is written", + ); + } + + status.note_context_from(&store, &step(run_id, 1)); + assert_eq!(status.context, Some(50)); +} + +/// The newest assembly wins, and it is the estimate for the section rather than +/// what the provider billed for the whole request. +#[test] +fn the_share_follows_the_latest_assembly_as_the_run_goes_on() { + let (store, run_id) = assembled(1, 2_000); + let mut status = under(&tight()); + status.note_context_from(&store, &step(run_id, 1)); + assert_eq!(status.context, Some(25)); + + store + .record_context_event( + run_id, + &ContextEvent::assembled(2, "carried=9 stubbed=1 reread=0", 6_000), + ) + .expect("a second assembly"); + status.note_context_from(&store, &step(run_id, 2)); + assert_eq!( + status.context, + Some(75), + "the window is not filling up at a rate the last fold could have told us", + ); +} + +/// A trace with no assembly recorded — a run that failed before it composed +/// anything — leaves the field exactly as it was. +#[test] +fn a_run_with_nothing_assembled_yet_changes_nothing() { + let store = Store::memory().expect("an in-memory store"); + let run_id = store.start_run("summarise", "openrouter").expect("a run"); + let mut status = under(&tight()); + status.note_context(4_000); + + status.note_context_from(&store, &step(run_id, 1)); + + assert_eq!( + status.context, + Some(50), + "an absent row is not a report of zero tokens assembled", + ); +} + +// --------------------------------------------------------------------------- +// The fold, which is still the better answer at the moment it happens +// --------------------------------------------------------------------------- + +/// `EventKind::Compacted` survives, and now divides by the same window. +/// +/// It reports the section's new size the instant it shrinks, before any step has +/// assembled against it, so it is genuinely earlier than the trace read — and the +/// two agree, because `after_tokens` and `ContextEvent::est_tokens` are both the +/// assembler's estimate of the observation section. +#[test] +fn a_fold_reports_against_the_configured_window_too() { + let mut app = App::new(DARK, "anthropic/claude-sonnet-4.5"); + app.status.budgets = Budgets::in_force(&tight()); + app.event( + &RunEvent::new( + 1, + 4, + EventKind::Compacted { + through_step: 4, + before_tokens: 7_600, + after_tokens: 2_000, + }, + ), + Duration::ZERO, + ); + assert_eq!( + app.status.context, + Some(25), + "2k of the 8k window this file configured, not of io-harness's 24k", + ); +} + +/// The fold and the trace cannot disagree: fed the same number, they produce the +/// same share. One quantity, one denominator, two arrival times. +#[test] +fn the_fold_and_the_trace_are_the_same_arithmetic() { + let (store, run_id) = assembled(4, 2_000); + let mut from_trace = under(&tight()); + from_trace.note_context_from(&store, &step(run_id, 4)); + + let mut app = App::new(DARK, "anthropic/claude-sonnet-4.5"); + app.status.budgets = Budgets::in_force(&tight()); + app.event( + &RunEvent::new( + 1, + 4, + EventKind::Compacted { + through_step: 4, + before_tokens: 7_600, + after_tokens: 2_000, + }, + ), + Duration::ZERO, + ); + + assert_eq!(app.status.context, from_trace.context); +} + +// --------------------------------------------------------------------------- +// What survives a change of conversation +// --------------------------------------------------------------------------- + +/// The share is a run fact and the window is a session fact, and `forget_run` +/// draws the line between them. +/// +/// The share was an observation the undone run made about its own ledger: a rewind +/// or a `/resume` puts a different conversation on screen, whose section that +/// number never measured. The window is not cleared because nothing about it +/// changed — `io.toml` does not move while a session runs, which is the same +/// argument `Status::budgets` as a whole already rests on. +#[test] +fn a_rewind_forgets_the_share_and_keeps_the_window() { + let mut status = under(&tight()); + status.note_context(4_000); + assert_eq!(status.context, Some(50)); + + status.forget_run(); + + assert_eq!( + status.context, None, + "the section belonged to the undone run" + ); + assert_eq!( + status.budgets.window, + Some(8_000), + "the file did not change, so the next turn's window has not either", + ); + + // And the very next step of the next turn fills it again, with no contract + // rebuild in between. + let (store, run_id) = assembled(1, 6_000); + status.note_context_from(&store, &step(run_id, 1)); + assert_eq!(status.context, Some(75)); +} + +// --------------------------------------------------------------------------- +// The page +// --------------------------------------------------------------------------- + +/// `/status` names the window even before a turn has run. +/// +/// The old row said `not known until the context has been folded once`, which was a +/// true description of a defect rather than of a session. The window is knowable +/// from the moment a contract exists, and it is the half an operator checking +/// whether their `[run.context]` table took effect is actually looking for. +#[test] +fn the_status_page_names_the_window_before_anything_has_been_assembled() { + let dir = tempfile::tempdir().expect("a workspace"); + let store = Store::memory().expect("an in-memory store"); + let session = io_harness::Session::open(&store, dir.path()).expect("a session"); + let policy = io_harness::Policy::permissive(); + let contract = tight(); + + let page = |status: &Status| { + io_cli::status::committed(status, &session, &policy, &contract, None, &DARK, 100) + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n") + }; + + let fresh = page(&under(&contract)); + assert!( + fresh.contains("context: nothing assembled yet") && fresh.contains("8.0k"), + "the window is knowable before the first turn and the page says so: {fresh}", + ); + + let mut status = under(&contract); + status.note_context(4_000); + let filled = page(&status); + assert!( + filled.contains("context: 50% of a 8.0k window"), + "the page names the denominator it divided by: {filled}", + ); + assert!( + !filled.contains("24.0k"), + "no surface may still be quoting io-harness's crate default: {filled}", + ); +} + +// --------------------------------------------------------------------------- +// The driver +// --------------------------------------------------------------------------- + +/// The trace read has to be wired into the event loop, and nothing under `tests/` +/// links the binary — so this reads the driver as text, the way `tests/compact.rs` +/// already does for the fold it reports. +/// +/// **Both drain paths, and that is the whole of the assertion.** `src/main.rs` +/// runs the events twice — once on the select's drain half while the turn future +/// is suspended, and once after the turn has returned, because the last step of a +/// turn is exactly the one whose event the select loses to the turn's own return. +/// `commit_edits` is called from both for that reason. A share wired into one of +/// them would be a field that is correct all through a turn and stale on its final +/// step, which is the reading an operator trusts least. +#[test] +fn the_driver_reads_the_assembly_on_both_event_paths() { + let driver = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/main.rs"), + ) + .expect("the driver"); + + let called = driver.matches("note_context_from(").count(); + let edits = driver.matches("commit_edits(app, store,").count(); + assert!( + called >= edits && edits > 0, + "the assembled share is read on {called} of the {edits} paths that already \ + read the trace per step; a field filled on one drain half is stale on the \ + last step of every turn", + ); +} diff --git a/tests/docs.rs b/tests/docs.rs index af7b3e2..2f2eea2 100644 --- a/tests/docs.rs +++ b/tests/docs.rs @@ -227,6 +227,10 @@ fn the_readme_quotes_the_budget_fields_the_status_line_actually_draws() { steps: Some(20), tokens: Some(200_000), duration: Some(std::time::Duration::from_secs(600)), + // The context window is a denominator rather than a remainder, so it + // draws no budget row of its own — `ctx N%` is where it shows. `None` + // here keeps this fixture about the three that do draw rows. + window: None, }; status.steps = Some(3); status.run_tokens = Some(187_600); @@ -313,6 +317,144 @@ fn no_documentation_surface_still_claims_the_old_asymmetry() { } } +/// Every comment in the crate, read for this release's version of the same +/// mistake: **a sentence saying a turn cannot be steered.** +/// +/// The test above catches the 0.14.0 shape of it in two prose files. This one is +/// the 0.17.0 shape and it reads the *source*, because that is where the claim +/// lived: through io-harness 0.66 no session entry point took a caller's +/// containment and a `SteerInbox` on one call, so a dozen comments explained the +/// arms in terms of what each gave up to get the other. 0.67.0 opened +/// `turn_bounded_steered` and `turn_contained_bounded_steered`, io-cli took both, +/// and every one of those sentences became folklore with a typeface — the exact +/// failure this file exists for. A reviewer reading the release diff sees the two +/// call sites change and has no reason to open `settings.rs`; this does. +/// +/// **Comment lines only, and that is the whole of the narrowing.** Two things +/// must go on saying these words and neither is a claim about today: +/// +/// - the assertions that hold io-harness's *deaf* entry points to their name — +/// `turn_bounded_observed` really does take no inbox, and `tests/steer.rs` says +/// so in a `format!` — and the arm of `tests/contain.rs` that asserts +/// `"cannot be steered"` is absent from the containment notice. Both are code, +/// not comments, so neither is read here. +/// - `CHANGELOG.md`, which is a record of releases rather than a description of +/// this one: its 0.14.0 and 0.11.0 entries were true when they were written and +/// rewriting them would be falsifying the history. Its `0.17.0` section is +/// checked by `the_changelog_has_a_section_for_this_version` instead. +/// +/// And the sentences explaining why `Ctrl+C` is **not** on the inbox are the +/// point of the release rather than a violation of it. They say the interrupt +/// travels as `Flow::Cancel` on the observer; none of them says a turn cannot be +/// steered, so none of the phrases below can match one. +/// +/// Sabotage: put any single corrected comment back — `settings.rs`'s "neither +/// turn takes a `SteerInbox` any more", `main.rs`'s "Neither turn takes a +/// `SteerInbox`", `commands.rs`'s "fan out, or be steered", `README.md`'s "no +/// session turn takes a steer inbox". Only this test fails, and it names the file +/// and the sentence. +#[test] +fn no_comment_still_says_a_turn_cannot_be_steered() { + // Each of these was a true sentence in some release and is a false one now. + // Written as the words that carry the claim rather than as whole sentences, + // because the sentence around them is what an author rewrites while leaving + // the claim standing. + const FALSEHOODS: &[&str] = &[ + // "no entry point takes a contract and an inbox together" + "containment and a steer inbox together", + "a contract and a steer inbox together", + // "neither arm takes an inbox" + "neither takes a steer inbox", + "neither turn takes a `SteerInbox`", + "Neither turn takes a `SteerInbox`", + "takes a `SteerInbox` any more", + "no session turn takes a steer inbox", + "session turn takes a `SteerInbox`", + // "a contained turn takes no inbox" + "A contained turn takes no", + "contained turn takes no `SteerInbox`", + // "containment is what decides whether a turn can be steered" + "fan out, or be steered", + "be steered and one that can fan out", + "either fans out or is steerable", + "turned steering off", + "cannot be steered", + ]; + + let mut sources = Vec::new(); + collect(&repo().join("src"), &mut sources); + collect(&repo().join("tests"), &mut sources); + + for path in sources { + // This file, and only this file, is allowed to write the sentences down: + // the list above is what they are, and the doc comment names four of them + // so the sabotage arm can be run without going to find them. A gate that + // could not quote what it forbids would have to describe it instead, and + // a description is the thing that drifts. + if path.file_name().is_some_and(|name| name == "docs.rs") { + continue; + } + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path:?}: {e}")); + let shown = path.strip_prefix(repo()).unwrap_or(path.as_path()); + for (number, line) in text.lines().enumerate() { + if !line.trim_start().starts_with("//") { + continue; + } + for claim in FALSEHOODS { + assert!( + !line.contains(claim), + "{}:{} still says {claim:?}. A contained turn CAN be steered \ + since 0.17.0 — both arms are driven through the `_steered` \ + entry points and both hold a `SteerInbox`. Containment \ + decides fan-out and nothing else.\n {}", + shown.display(), + number + 1, + line.trim(), + ); + } + } + } + + // The README carries the same claim in prose rather than in comments, so the + // whole file is read. + let readme = read("README.md"); + for claim in FALSEHOODS { + assert!( + !readme.contains(claim), + "README.md still says {claim:?}, which has not been true since 0.17.0", + ); + } + + // And the positive half, because a gate made only of absences passes on a + // file that says nothing at all. Somewhere the README has to state what is + // true now, or an operator reading it learns that containment costs a steer + // from the silence where the correction should be. + let said = readme.to_lowercase(); + assert!( + said.contains("steer inbox") || said.contains("/steer"), + "the README should say a turn can be spoken to while it runs", + ); + assert!( + said.contains("contained turn can be steered"), + "the README should say the containment switch no longer decides it", + ); +} + +/// Every `.rs` file under `dir`, recursively. +fn collect(dir: &std::path::Path, into: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect(&path, into); + } else if path.extension().is_some_and(|ext| ext == "rs") { + into.push(path); + } + } +} + #[test] fn the_removed_step_cap_is_announced_where_it_was_promised() { // F12. The key is GONE from `CliSettings` as of 0.16.0, and the notice is diff --git a/tests/exec.rs b/tests/exec.rs index ed94973..92474ba 100644 --- a/tests/exec.rs +++ b/tests/exec.rs @@ -251,9 +251,10 @@ impl Provider for Endless { #[tokio::test] async fn f3_a_step_ceiling_exits_three_rather_than_zero() { - // The budget arrives through `[run]`, which `Config::apply_to` applies — the - // section an interactive turn cannot use, because `turn_steered` builds its - // own contract. This is the first path in the product where it has an effect. + // The budget arrives through `[run]`, which `Config::apply_to` applies. This + // was the first path in the product where it had an effect; since 0.14.0 an + // interactive turn is built from the same call, so the section bounds a + // session too — see `tests/contract.rs`. let (_dir, store, mut session, config) = workspace("[run]\nmax_steps = 2\n"); let result = exec::turn( diff --git a/tests/picker.rs b/tests/picker.rs index b3edf38..ef28ca5 100644 --- a/tests/picker.rs +++ b/tests/picker.rs @@ -282,7 +282,7 @@ fn f8_escape_cancels_the_picker_rather_than_clearing_the_query() { #[test] fn f8_the_query_is_drawn_in_place_of_the_title_and_costs_no_row() { // The sabotage: give the query a line of its own. The in-session viewport is - // four rows fixed at attach, so a query line above the title would leave + // a height fixed at attach, so a query line above the title would leave // `/resume` two visible rows — and three was already the count a live run // found unusable. Both halves are asserted here: the line count, and that the // first matched row is drawn immediately under the query. diff --git a/tests/status.rs b/tests/status.rs index de1c559..7494c1a 100644 --- a/tests/status.rs +++ b/tests/status.rs @@ -357,11 +357,16 @@ fn the_containment_field_carries_the_backend_and_not_only_the_mode() { } /// Context pressure appears once something has said what it is, and says it as a -/// share of the budget io-harness itself declares rather than of a number copied -/// into this repository. +/// share of the window **this session's contract** declares — not of the crate +/// default, which is what it divided by until 0.17.0 and is why the field was +/// silently wrong for every operator who set `[run.context]`. #[test] fn the_context_field_appears_when_a_fold_reports_one() { let mut app = App::new(DARK, "opus-5"); + // The denominator, handed over the way the driver hands it: from the contract + // the turn is running under. Without it there is no window and the field stays + // away, which is the honest answer and is asserted in its own test. + app.status.budgets = Budgets::in_force(&budgeted()); assert!(!app.status.line(120, &DARK).to_string().contains("ctx")); app.event( @@ -609,6 +614,9 @@ fn f12_a_plan_of_no_items_is_absent_rather_than_zero() { #[test] fn the_run_fields_do_not_outlive_the_run_that_set_them() { let mut app = App::new(DARK, "opus-5"); + // The window `ctx N%` is a share of. A fold with no contract behind it reports + // nothing from 0.17.0 — see `the_context_field_appears_when_a_fold_reports_one`. + app.status.budgets = Budgets::in_force(&budgeted()); app.set_posture(Some(io_cli::settings::Posture::Workspace)); app.event(&step(1, 12_400), Duration::ZERO); app.event( @@ -637,7 +645,13 @@ fn the_run_fields_do_not_outlive_the_run_that_set_them() { app.status.forget_run(); let after = app.status.line(200, &DARK).to_string(); - for fact in ["tok", "ctx", "seatbelt", "plan"] { + // The run's OWN spellings, not any row that happens to hold the same word. + // The budgets are the file's rather than the run's and deliberately survive a + // forget — they are what the next turn will run under — so a sweep for a bare + // `"tok"` now finds `left 0/10.0k tok` and fails on the field working + // correctly. What must go is the count this run spent and the share it + // reported, and that is what these name. + for fact in ["12.4k tok", "ctx ", "seatbelt", "plan 1/3"] { assert!( !after.contains(fact), "{fact:?} outlived the run that reported it: {after:?}", @@ -1420,6 +1434,12 @@ fn committed_of( /// it and through the one function the driver calls. fn reported() -> App { let mut app = App::new(DARK, "anthropic/claude-sonnet-4.5"); + // **Before the events, from 0.17.0, and the order is the assertion.** `ctx N%` + // is a share of the window the CONTRACT declares, so a `Status` that was never + // handed one has no denominator and reports nothing rather than a share of the + // crate default — which is the defect F10 exists to remove, and a fixture that + // set the budgets afterwards would be asserting the old behaviour. + app.status.budgets = Budgets::in_force(&budgeted()); app.event( &event(EventKind::Started { goal: "summarise the module".into(), @@ -1716,8 +1736,15 @@ fn f10_a_fact_no_event_has_reported_reads_as_unknown_rather_than_as_a_default() page.contains("nothing has been drawn against the tree yet"), "{page}", ); + // **Half of this fact IS knowable before a turn, and 0.17.0 says the half it + // knows.** The share needs an assembly to have happened; the WINDOW is the + // contract's, so it is knowable at the idle prompt and is exactly what an + // operator checking `[run.context]` came to the page for. The old sentence + // said "not known until the context has been folded", which withheld a number + // this page was already holding — and named a fold, which is no longer the + // only thing that reports one. assert!( - page.contains("context: not known until the context has been folded"), + page.contains("context: nothing assembled yet — the window is"), "{page}", ); assert!(page.contains("mcp: 0 of 2 configured connected"), "{page}"); diff --git a/tests/surfaces.rs b/tests/surfaces.rs index 370f5ee..b964496 100644 --- a/tests/surfaces.rs +++ b/tests/surfaces.rs @@ -2,7 +2,7 @@ //! this text", asserted at the boundary each of them actually crosses. //! //! The three share one premise and it is the reason they are in one file: **the -//! viewport is four rows and never grows**. Everything that shows more of +//! viewport never grows**. Everything that shows more of //! something is therefore written *upward*, into the terminal's own scrollback, //! where the terminal's search, selection and copy-mode already work — and //! everything that hands text to the reader's own machine goes out as an escape From 5c040724ab3db88aa2c0d94cb3c99641c1eef3cd Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Wed, 26 Aug 2026 09:40:09 +0530 Subject: [PATCH 09/12] chore(release): 0.17.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f9c123b..e616c89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1035,7 +1035,7 @@ dependencies = [ [[package]] name = "io-cli" -version = "0.16.0" +version = "0.17.0" dependencies = [ "clap", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index 47f016a..b0aba7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "io-cli" -version = "0.16.0" +version = "0.17.0" edition = "2021" # io-harness declares 1.95, set by `libsqlite3-sys` calling the std `cfg_select!` # macro from its build script. Nothing here can be lower than what the harness From ac2843c894f5f3ee606a049e91337f1d51a9cfb6 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Wed, 26 Aug 2026 09:57:27 +0530 Subject: [PATCH 10/12] fix(status): `ctx N%` and `/context` report one number, not two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Found by driving the binary, ninth release running.** `/context` totalled 4,363 tokens of 24,000 while the status line one keystroke away read `ctx 0%`. Neither number was wrong on its own: the page measures the whole request, and the field measured the observation section — which is what `ContextBudget` bounds and what `Compacted::after_tokens` reports, and which early in a turn is a rounding error next to the tool catalogue. The pair was wrong. Two surfaces disagreeing about a word, on one screen, one keystroke apart, is worse than either number is useful — and the percentage is what makes an operator open the page, so the page is the one it has to match. Both now come from one snapshot through one helper, by the same calls: the request's sections, totalled, over the window the contract declares. The trace read is kept as the fallback rather than deleted, because it answers a real window — a step lands before the completion call after it is snapshotted, so on the very first step the section the trace records is the only number there is. Re-cut the binary and re-drove the capture: `ctx 18%` against `4363 tokens of 24000`. The test asserts the field IS the page's total over the page's window rather than that the two are close, and asserts the fixture is not the degenerate agreement of two zeroes — which is the shape the defect wore on screen. The live evidence is in the release's evidence directory: four captures, their scripts and their timelines. It includes the run where a steer was sent and NOT delivered, because the turn reached its last step before the next boundary — the interface said so, which is the one delivery fact it can state. --- src/main.rs | 42 ++++++++++++++++++++++++++++-- src/status.rs | 59 ++++++++++++++++++++++++++++++------------ tests/context.rs | 44 +++++++++++++++++++++++++++++++ tests/context_share.rs | 20 +++++++++++--- 4 files changed, 143 insertions(+), 22 deletions(-) diff --git a/src/main.rs b/src/main.rs index 2daaa37..0782789 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1619,6 +1619,7 @@ async fn loop_over Result>( // steered, so that is not the word for the difference. contained.then_some(containment.as_ref()).flatten(), &capabilities, + &seen, planning, // Taken rather than read: one request, one turn. A queue // of three prompts must not fold three times. @@ -1806,6 +1807,10 @@ async fn turn( config: &Config, containment: Option<&io_harness::Containment>, capabilities: &io_cli::contract::Capabilities, + // The last request this session's provider was handed. `/context` reads it to + // say what is in the window, and `ctx N%` reads it so the two say the same + // thing — see `note_context`, and the live run that found them disagreeing. + seen: &io_cli::context::Seen, // Whether this turn proposes a plan before it works. The operator's `/plan`, // and nothing else — a caps configuration decided it through 0.11.0, which // is how every contained turn ended up stopping for one. @@ -1986,7 +1991,12 @@ async fn turn( // every event, for the reason `commit_edits` above it is: the // assembly is written once a step and reading it per event would // be one store query per token. - app.status.note_context_from(store, &event); + // + // The REQUEST first, so the line and `/context` cannot disagree — + // a live run caught them saying 0% and 4,363-of-24,000 about the + // same turn. The trace is the fallback for the window between a + // step landing and the first completion call being seen. + note_context(app, store, &event, seen, &contract); commit_viewed(screen, app, &root, policy, &event)?; commit_fold(app, store, &event, &mut folding); paint(screen, app)?; @@ -2310,7 +2320,7 @@ async fn turn( // And on the drain, for the same race the two lines above it are here // for: the last step of a turn is exactly the one whose event the select // loop loses to the turn's own return. - app.status.note_context_from(store, &event); + note_context(app, store, &event, seen, &contract); // And the picture, for the same reason and the same race: a `view_image` // on the turn's last step is exactly the one the drain would otherwise // lose. @@ -2394,6 +2404,34 @@ async fn turn( /// A read that fails degrades to a line saying so. A run whose work succeeded is /// not a run to panic over because the trace could not be re-read, and silence /// would say the step changed nothing. +/// Keep `ctx N%` saying what `/context` would say. +/// +/// **One quantity, one source, because a live run found two.** The page totals +/// the request against the window the contract declares; the field divided the +/// observation section by the same window and read `0%` where the page read +/// eighteen. Both numbers were defensible and the pair was not: the percentage is +/// what makes an operator open the page, so it has to be the page's own number. +/// +/// The trace is the fallback rather than the answer. A step lands before the +/// completion call that follows it is snapshotted, so on the very first step +/// there is no request to measure and the section the trace records is the only +/// number there is — better than a blank field, and it converges the moment a +/// request has been seen. +fn note_context( + app: &mut App, + store: &Store, + event: &io_harness::RunEvent, + seen: &io_cli::context::Seen, + contract: &io_harness::TaskContract, +) { + if let Some(request) = seen.latest() { + app.status + .note_context_request(&request, contract, contract.max_tokens); + } else { + app.status.note_context_from(store, event); + } +} + /// Report a fold that was asked for — once, and only from the event that /// announces it. /// diff --git a/src/status.rs b/src/status.rs index b0bde0b..e7dd019 100644 --- a/src/status.rs +++ b/src/status.rs @@ -595,22 +595,21 @@ impl Status { /// The same share, taken from the durable trace once a step has landed. /// - /// **This is the numerator, and the store is the only place it exists.** - /// io-harness records `ContextEvent::assembled` — the assembler's own estimate - /// for the section it has just built — as one row per step, and emits no event - /// carrying that number. `EventKind::Compacted` says how big the section is - /// only on the steps where a fold happened, and `PromptComposed` counts the - /// *system* block in bytes, which is a different section measured in a - /// different unit. So a share that reads the event stream alone is blank until - /// a fold, and no arithmetic anywhere could have fixed that. - /// - /// `est_tokens` and not `reported_tokens`, though the row carries both. - /// `reported_tokens` is what the provider billed for the whole request — - /// system block, tools, transcript and all — while `est_tokens` is the - /// observation section alone, which is the thing `ContextBudget` bounds and - /// the thing `Compacted::after_tokens` reports. `ctx N%` has to mean one - /// quantity, and mixing the two would make the number jump at every step - /// boundary for no reason a reader could see. + /// **NOT what `ctx N%` reports, and the reason is a live run.** This reads + /// `ContextEvent::assembled` — the observation section alone, which is what + /// `ContextBudget` bounds and what `Compacted::after_tokens` reports. It is a + /// coherent quantity and it was the field's numerator until the binary was + /// driven for real: `/context` totalled **4,363 tokens of 24,000** while the + /// status line one keystroke away said **`ctx 0%`**, because the page measures + /// the whole request and this measures the ledger inside it. Two surfaces + /// disagreeing about a word, on one screen, is worse than either number is + /// useful — and the percentage is what makes an operator open the page, so the + /// page is the one that must be right. + /// + /// So the field now reports what the page totals, from the same snapshot, and + /// this is kept for the section number the page still shows. It answers "how + /// full is the part a fold would shrink"; `ctx N%` answers "how full is the + /// window", which is the question an overflow is the answer to. /// /// **Anchored on `Step`, for the reason `main.rs`'s `commit_edits` is.** /// io-harness documents `Step` as emitted once the step has been committed to @@ -630,6 +629,34 @@ impl Status { /// decoration on a status line: a run whose work succeeded is not one to /// interrupt because a trace could not be re-read, and unlike a diff there is /// nothing here worth spending a scrollback row to apologise for. + /// The share `ctx N%` reports: the whole request, over the window. + /// + /// **The same numbers `/context` puts on its page, from the same snapshot, so + /// the two cannot disagree.** They did, and a live run is what found it: the + /// page totalled 4,363 tokens of 24,000 while the line said `ctx 0%`. The + /// percentage is what makes an operator open the page; a percentage that + /// contradicts the page is worse than no percentage at all. + /// + /// Taken from the request rather than the trace because that is what the + /// window bounds — an overflow is refused on the whole request, not on the + /// observations inside it — and because a request is what a page can show. + /// Where no request has been seen yet, the caller falls back to + /// [`Status::note_context_from`], which reads the section the trace records. + pub fn note_context_request( + &mut self, + seen: &crate::context::Request, + contract: &io_harness::TaskContract, + remaining: Option, + ) { + let window = crate::context::window(contract, remaining); + if window == 0 { + return; + } + let total = crate::context::total(&crate::context::sections(seen, contract)); + let share = (total as f64 / window as f64 * 100.0).round(); + self.context = Some(share.clamp(0.0, 100.0) as u8); + } + pub fn note_context_from(&mut self, store: &io_harness::Store, event: &io_harness::RunEvent) { let io_harness::EventKind::Step { .. } = &event.kind else { return; diff --git a/tests/context.rs b/tests/context.rs index 5717af9..6a31b89 100644 --- a/tests/context.rs +++ b/tests/context.rs @@ -492,3 +492,47 @@ fn f7_the_page_draws_in_ascii_and_says_so_before_a_turn_has_run() { ); } } + +/// F7 + F10 — the page and the status line report ONE number. +/// +/// **The defect a live run found, pinned so it cannot come back.** `/context` +/// totalled 4,363 tokens of 24,000 while the status line one keystroke away said +/// `ctx 0%`, because the page measured the whole request and the field measured +/// the observation section inside it. Each number was defensible on its own; the +/// pair was not, and the percentage is what makes an operator open the page. +/// +/// So this asserts the field IS the page's total over the page's window, computed +/// from the same snapshot by the same calls — not merely that the two are close, +/// which is a tolerance somebody widens later. +#[test] +fn f10_the_status_share_is_the_page_total_over_the_page_window() { + let contract = contract(); + let seen = Request::of(&request()); + + let sections = context::sections(&seen, &contract); + let total = context::total(§ions); + let window = context::window(&contract, contract.max_tokens); + assert!( + total > 0 && window > 0, + "the fixture has to put something in a window for this to mean anything", + ); + + let mut status = io_cli::status::Status::new("a-model"); + status.budgets = io_cli::status::Budgets::in_force(&contract); + status.note_context_request(&seen, &contract, contract.max_tokens); + + let expected = (total as f64 / window as f64 * 100.0).round() as u8; + assert_eq!( + status.context, + Some(expected), + "the line says {:?} where the page says {total} of {window}", + status.context, + ); + + // And it is not the degenerate agreement of two zeroes, which is exactly the + // shape the defect wore on screen. + assert!( + expected > 0, + "a fixture where both read zero would pass this test and prove nothing", + ); +} diff --git a/tests/context_share.rs b/tests/context_share.rs index 6629ada..07f4f08 100644 --- a/tests/context_share.rs +++ b/tests/context_share.rs @@ -529,12 +529,24 @@ fn the_driver_reads_the_assembly_on_both_event_paths() { ) .expect("the driver"); - let called = driver.matches("note_context_from(").count(); + // `note_context` and not `note_context_from`: a live run found the field and + // `/context` disagreeing about the same turn — 0% against 4,363 of 24,000 — + // because one measured the observation section and the other the request. The + // driver now goes through one helper that prefers the request and falls back + // to the trace, so what this counts is that helper. + let called = driver.matches("note_context(app, store,").count(); let edits = driver.matches("commit_edits(app, store,").count(); assert!( called >= edits && edits > 0, - "the assembled share is read on {called} of the {edits} paths that already \ - read the trace per step; a field filled on one drain half is stale on the \ - last step of every turn", + "the share is read on {called} of the {edits} paths that already read per \ + step; a field filled on one drain half is stale on the last step of every \ + turn", + ); + // And the fallback is still reachable, because a step lands before the + // completion call after it is snapshotted: on the very first step the trace is + // the only number there is. + assert!( + driver.contains("note_context_from(store, event)"), + "the request is preferred, but the trace is what answers before one exists", ); } From 31b118545db43461f86738e9ad4fa88513905f73 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Wed, 26 Aug 2026 10:01:15 +0530 Subject: [PATCH 11/12] refactor(mcp): name the three per-server facts instead of positioning them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clippy's type-complexity rule caught the tuple this release grew to three, and its judgement is the right one: two facts read fine positionally and the third — the offered count 0.68.0 put on the event — is where `entry.2` stops saying what it holds at the call site. `asked` and `offered` are exactly the pair a reader of this module must not confuse, so they are named. No behaviour change; the same events fold in the same way. --- src/servers.rs | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/src/servers.rs b/src/servers.rs index b0ae9e6..9f922e3 100644 --- a/src/servers.rs +++ b/src/servers.rs @@ -106,11 +106,29 @@ pub struct Server { /// field answers "is anything connected" for a one-row status line, and this /// answers "what happened to each of them" for a panel. One is not derivable from /// the other — the pair has no server names in it at all. +/// What this session has seen one server do. +/// +/// **A struct rather than the tuple this was, and clippy is right about when.** +/// Two facts read fine positionally; the third — the offered count 0.68.0 put on +/// the event — is where `entry.2` stops saying what it holds at the call site. The +/// names are the documentation now that there is more than one kind of number +/// here, and `asked` and `offered` are exactly the pair a reader must not confuse. +#[derive(Debug, Clone, Default)] +struct Seen { + /// Distinct tool names this session has called on the server. A lower bound + /// on what it offers, and counted here because no event states it. + asked: BTreeSet, + /// The tool of the last call that failed, if one did. + failed: Option, + /// How many tools the server announced, if an event ever said. `None` is not + /// zero — see [`Observed::event`]. + offered: Option, +} + #[derive(Debug, Clone, Default)] pub struct Observed { - /// Distinct tool names seen per server, the last failure if there was one, - /// and the offered count if an event ever stated it. - seen: BTreeMap, Option, Option)>, + /// What each server has been seen to do, by the id it was configured under. + seen: BTreeMap, } impl Observed { @@ -137,15 +155,15 @@ impl Observed { // say about the count, not a statement that there are none — so it must // neither write a zero nor erase a count an earlier event stated. if let Some(offered) = tools { - entry.2 = Some(*offered); + entry.offered = Some(*offered); } if let Some(tool) = tool { - entry.0.insert(tool.clone()); + entry.asked.insert(tool.clone()); // `ok` is an `Option`: `Some(false)` is a failure, and `None` // is a call whose outcome the event did not carry, which is not the // same thing and must not be drawn as one. if *ok == Some(false) { - entry.1 = Some(tool.clone()); + entry.failed = Some(tool.clone()); } } } @@ -163,11 +181,11 @@ impl Observed { pub fn of(&self, id: &str) -> Reached { match self.seen.get(id) { None => Reached::NotYet, - Some((tools, failure, offered)) => match failure { + Some(seen) => match &seen.failed { Some(tool) => Reached::Failed { tool: tool.clone() }, None => Reached::Answered { - tools: tools.len(), - offered: *offered, + tools: seen.asked.len(), + offered: seen.offered, }, }, } From e7455a03d8f2098c49b1fc3798477f552b6648b6 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Wed, 26 Aug 2026 10:18:00 +0530 Subject: [PATCH 12/12] fix: four defects an adversarial review found behind a green suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every check was green — 68 binaries, 893 tests, clippy clean, the live captures taken — and a read-only review of the diff found four real defects. All four are the same shape, and it is the shape that was being hunted: **the tests exercise each feature once, and the defect only appears the second time.** **`/clear` never forgot the window.** `/resume`, `/fork` and the rewind all drop the recorded request beside their `forget_run`; `/clear` did not — and it is the site `Seen::forget`'s own doc names first. So `/context` drew a whole conversation the operator had just discarded, on a session with no turns in it, while the `ctx` field beside it was blank because `forget_run` did clear that. Two surfaces disagreeing about one fact, which is what this release exists to stop, reappearing through a door nobody had opened yet. **A `/steer` on a turn's last step destroyed the queue.** Sending REMOVES each line to hand it over, and the send cannot fail while the inbox is alive, so the footer said "3 sent" and meant it. If the turn then ended before another step boundary, the lines were reported as not delivered and dropped — three prompts' worth of work gone, from the release whose headline is that a mid-turn prompt is no longer destroyed. The window is not narrow either: `/steer` is typed exactly when the agent looks close to done, which is exactly when there is no boundary left. They now go back to the FRONT of the queue, in order, and run as the next turns. A turn the operator STOPPED still drops them, because one press of the stop key must not start another — the old comment claimed a composer was waiting to catch them, and nothing ever put them in it. **`ctx N%` divided by the whole run budget rather than what is left.** io-harness assembles against the unspent remainder — and `context::window`'s own doc says so, in this crate, above the function no call site used that way. With 40k budgeted and 30k spent the real window is the floor while io-cli still printed the flat maximum and a share several times too small: under-reporting pressure exactly when there is pressure. Only moves when `[run] max_tokens` is set, which is why the fixtures missed it. **The `… N more` row counted what was outside the window rather than under it.** Nine queued, four rows, marked on the eighth: the rows read 6, 7, 8 and the count said six more, five of which were above. Correct on the first draw, when nothing is marked and the window starts at the top; wrong from the first arrow. The test that pinned it had baked the wrong number in. Plus three smaller ones. The fleet view and the queue could be open together, so the queue took the blank row for a surface drawn in its place and `Enter` reached into a queue behind a view covering it — a surface acting while invisible. `/steer do the thing` missed its guard and got the mid-turn refusal, which tells the operator to interrupt the turn first: the opposite of what the command does. And the fleet's own `Esc` guard reads a field this function has already emptied, so it was always true — pre-existing, found because the queue's version of the same guard gets it right and its comment describes the trap by name. Six new tests, each driving the SECOND use rather than the first. The review also confirmed what holds: no mutation of the queue leaves the status count stale, the one `unreachable!` is genuinely unreachable, no indexing path in the surface can panic, a mark cannot survive a drain, the decorator delegates all twelve provider methods with no default firing silently, and `Ctrl+C` is untouched on every path. --- src/app.rs | 55 +++++++++++++++++++++++++-- src/main.rs | 85 ++++++++++++++++++++++++++++++++++++++---- src/queue.rs | 17 +++++++-- tests/context.rs | 53 ++++++++++++++++++++++++++ tests/queue.rs | 65 ++++++++++++++++++++++++++++++++ tests/queue_edit.rs | 8 +++- tests/queue_surface.rs | 84 +++++++++++++++++++++++++++++++++++++++++ tests/steer.rs | 6 ++- 8 files changed, 357 insertions(+), 16 deletions(-) diff --git a/src/app.rs b/src/app.rs index 13399c3..db475ad 100644 --- a/src/app.rs +++ b/src/app.rs @@ -743,6 +743,21 @@ impl App { self.queue_open && self.mode == Mode::Running && !self.prompts.is_empty() } + /// Whether the queue will actually be drawn on the next frame. + /// + /// **Open is not drawn, and the difference is one row.** The fleet view is + /// rendered in this surface's place, in the composer's own rect, so a queue + /// that is open behind it draws nothing — and the layout must not release the + /// blank row above the activity line for a surface that will not use it. The + /// row bought nothing and the fleet quietly grew by one. + /// + /// A named predicate rather than the expression inline, because the two + /// readings clippy offers for that expression are each shorter and neither + /// says which surface wins. + pub fn queue_drawn(&self) -> bool { + self.queue_open() && !self.fleet_open + } + /// Drop everything still waiting, and report how much was dropped. /// /// An operator stopping a turn is stopping the session, not just the step in @@ -754,6 +769,27 @@ impl App { std::mem::take(&mut self.prompts).len() } + /// Put lines back at the FRONT of the queue, in order, and say how many wait. + /// + /// **For a `/steer` that emptied the queue into a turn nothing read.** The + /// send takes each line out to hand it over, and a turn that ends before its + /// next step boundary hands nothing over — so without this the lines are gone, + /// in the release whose promise is that a mid-turn prompt is not destroyed. + /// + /// The front rather than the back, because they were ahead of whatever was + /// queued after them and putting them behind it would silently reorder the + /// operator's work. The driver calls this only for a turn that ended on its + /// own; one the operator stopped drops them, for the same reason a stopped + /// turn drops the rest of the queue. + pub fn requeue_prompts(&mut self, lines: Vec) -> usize { + for (at, line) in lines.into_iter().enumerate() { + self.prompts.insert(at, line); + } + self.status.queued_prompts = self.prompts.len(); + self.queue_open = true; + self.prompts.len() + } + /// Remember an attached image and return the number its marker carries. /// /// One-based, because the marker is read by a person: `[Image #1]` is the @@ -1343,7 +1379,12 @@ impl App { self.fleet.move_by(1); return Command::None; } - KeyCode::Esc if self.armed.is_none() => { + // `armed` and not `self.armed`: this function took the arming out + // a few lines above, so the field is always `None` here and the + // guard was always true. Pre-existing, and found while writing the + // queue's version of the same guard — which gets it right, and + // whose comment describes the trap this one was in. + KeyCode::Esc if armed.is_none() => { self.fleet_open = false; return Command::None; } @@ -1384,7 +1425,11 @@ impl App { // function emptied a few lines above: `Esc` can be the second key of a // rebound chord, and a surface that stole it would be answering a sequence // the operator was half way through. - if self.queue_open() || self.queue.editing().is_some() { + // Not while the fleet view is up: it is drawn in this surface's place and + // takes the arrows above, so a queue acting here would be a surface acting + // while invisible — `Enter` at an empty prompt would take a line out of the + // queue into a composer the fleet is covering. + if !self.fleet_open && (self.queue_open() || self.queue.editing().is_some()) { let dash = self.theme.glyphs.dash; match key.code { KeyCode::Up | KeyCode::Down @@ -1722,7 +1767,11 @@ impl App { // third answer: it carries nothing by its own argument above, the queue // carries something, and the frame is the same height either way — which // is what N2 is really about. It comes back the moment the queue closes. - let air_rows = u16::from(area.height >= 8 && !self.queue_open()); + // `&& !fleet_open` because the fleet view is drawn INSTEAD of the queue, + // in the composer's own rect. Without that clause, queueing a line behind + // an open fleet view took the blank row for a surface that then did not + // draw — the row bought nothing and the fleet quietly grew by one. + let air_rows = u16::from(area.height >= 8 && !self.queue_drawn()); // **A rule over the composer, matching the one under it.** The footer has // opened with one since 0.1.0, and the prompt had a boundary on one side // only — so the composer read as part of whatever the turn had last diff --git a/src/main.rs b/src/main.rs index 0782789..c1b9900 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1514,6 +1514,17 @@ async fn loop_over Result>( // same sentence. Action::Clear => { if app.clear_conversation() { + // **The window belonged to the conversation being + // discarded.** `Seen::forget`'s own doc names `/clear` + // first of the three sites that must call it, and this was + // the one that did not — `/resume`, `/fork` and the rewind + // all do, beside their `forget_run`. Without it `/context` + // draws the whole of a conversation the operator has just + // thrown away, on a session with no turns in it, while the + // `ctx` field beside it is blank because `forget_run` did + // clear that. Two surfaces disagreeing about the same + // fact, which is what this release exists to stop. + seen.forget(); let root = session.root().to_path_buf(); match Session::open(&store, &root) { Ok(fresh) => { @@ -2144,7 +2155,12 @@ async fn turn( // folds nothing, and the request is spent under all // four, so the only thing that may say a fold happened // is `EventKind::Compacted`. - Command::Slash(ref line) if line.trim() == "compact" => { + // The first word, so `/compact …` reaches the arm that answers it + // rather than the mid-turn refusal below, which + // would tell the operator to interrupt the turn + // first — the opposite of what this command does. + Command::Slash(ref line) + if line.split_whitespace().next() == Some("compact") => { let dash = app.theme.glyphs.dash; let said = io_cli::compact::Said::asked(contract.compaction, true); if said == io_cli::compact::Said::Sent { @@ -2164,7 +2180,12 @@ async fn turn( app.say(Tone::Muted, said.line(dash)); } } - Command::Slash(ref line) if line.trim() == "steer" => { + // The first word, so `/steer …` reaches the arm that answers it + // rather than the mid-turn refusal below, which + // would tell the operator to interrupt the turn + // first — the opposite of what this command does. + Command::Slash(ref line) + if line.split_whitespace().next() == Some("steer") => { let dash = app.theme.glyphs.dash; let mut sent = 0usize; // The summary below is skipped when this is set, @@ -2341,15 +2362,53 @@ async fn turn( // is about a run that has already ended — a footer notice would be gone at // the next keystroke, and it would fight with `stopped` below. // - // Not re-queued. A turn that ended because the operator stopped it must not - // start another one, and a turn that ended on its own has a composer waiting - // with nothing in the way. - for lost in inbox.pending().messages { + // **And put back, unless the operator stopped the turn.** `/steer` REMOVES + // each line from the queue to send it, and the send cannot fail while the + // inbox is alive — so a turn that reaches its last step before the next + // boundary consumed the queue, delivered nothing, and left the operator with + // three prompts' worth of work in a sentence. In the release whose whole + // headline is that a mid-turn prompt is no longer destroyed. + // + // The window is not narrow either: `/steer` is typed exactly when the agent + // looks close to done, which is exactly when there is no boundary left. + // + // So they go back to the FRONT of the queue, in order, and the driver's drain + // runs them as the next turns — which is what the queue promised in the first + // place. A turn the operator STOPPED keeps the old behaviour and drops them, + // because one press of the stop key must not start another turn; the earlier + // comment claimed a composer was waiting to catch them, and nothing ever put + // them in it. + let lost = inbox.pending().messages; + for line in &lost { app.record( Tone::Muted, - format!("[mid-turn, not delivered] {}", lost.trim()), + format!("[mid-turn, not delivered] {}", line.trim()), ); } + if !lost.is_empty() { + if stopped { + app.record( + Tone::Muted, + format!( + "{} went with the turn you stopped", + if lost.len() == 1 { + "it".to_string() + } else { + format!("all {} of them", lost.len()) + } + ), + ); + } else { + let waiting = app.requeue_prompts(lost); + app.record( + Tone::Muted, + format!( + "back in the queue {} {waiting} waiting", + app.theme.glyphs.dash + ), + ); + } + } // **The fold that was asked for and never announced.** A conversation shorter // than `Compaction::keep_recent` has no prefix a paragraph could stand in for, @@ -2425,8 +2484,18 @@ fn note_context( contract: &io_harness::TaskContract, ) { if let Some(request) = seen.latest() { + // **What is LEFT of the run budget, not all of it.** io-harness assembles + // against the unspent remainder — a run low on budget gets a smaller + // window, down to the floor — and `context::window`'s own doc says so. + // Passing the flat maximum reports a window the turn can no longer afford + // and a share several times too small, which under-reports pressure + // exactly when there is pressure. Only moves when `[run] max_tokens` is + // set; with none, the harness's own expression is flat too. + let remaining = contract + .max_tokens + .map(|cap| cap.saturating_sub(app.status.run_tokens.unwrap_or(0))); app.status - .note_context_request(&request, contract, contract.max_tokens); + .note_context_request(&request, contract, remaining); } else { app.status.note_context_from(store, event); } diff --git a/src/queue.rs b/src/queue.rs index ef557b5..5efcbd8 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -414,11 +414,22 @@ pub fn rows_for( ) }) .collect(); - let hidden = waiting.len() - shown; - if hidden > 0 { + // **What is BELOW the window, not what is outside it.** The row sits under + // the last one drawn, so a count of everything unshown reads as a count of + // what follows — and once the mark has scrolled the window down, most of what + // is unshown is above. Nine queued, four rows, marked on the eighth: the rows + // read `6.` `7.` `8.` and the old count said six more, of which five were + // behind the operator. It was right on the first draw, when nothing is marked + // and the window starts at the top, and wrong from the first arrow — which is + // the shape this release has been hunting. + // + // What is above needs no row of its own: the numbers are absolute, so a + // window opening at `6.` says so by saying `6.`. + let below = waiting.len() - (first + shown); + if below > 0 { out.push(fit( &format!( - "{}{} {hidden} more", + "{}{} {below} more", if selected.is_some() { " " } else { "" }, glyphs.elision ), diff --git a/tests/context.rs b/tests/context.rs index 6a31b89..b310582 100644 --- a/tests/context.rs +++ b/tests/context.rs @@ -536,3 +536,56 @@ fn f10_the_status_share_is_the_page_total_over_the_page_window() { "a fixture where both read zero would pass this test and prove nothing", ); } + +/// The window is forgotten with the conversation it described. +/// +/// **The review found `/clear` was the one site that did not call this**, and +/// `Seen::forget`'s own doc names `/clear` first of the three. The consequence is +/// the release's own headline failure wearing different clothes: `/context` draws +/// a whole conversation the operator has just discarded, on a session with no +/// turns in it, while the `ctx` field beside it is blank because `forget_run` did +/// clear that — two surfaces disagreeing about the same fact. +/// +/// A source gate, because the sites are in the driver and nothing under tests/ +/// links the binary. +#[test] +fn every_site_that_forgets_a_run_forgets_the_window_with_it() { + let driver = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/main.rs"), + ) + .expect("the driver"); + + // FOUR sites, and the count is stated rather than derived because the fourth + // is why this test exists. Three drop the run's facts here in the driver — + // `/resume`, `/fork`, the rewind — and `/clear` drops them one layer down in + // `App::clear_conversation`, which is exactly how its missing `seen.forget()` + // hid: a reader comparing the two counts in this file would have found them + // equal at three and concluded all was well. + let forgets_run = driver.matches("forget_run()").count(); + let forgets_window = driver.matches("seen.forget()").count(); + assert_eq!( + forgets_run, 3, + "three sites drop a run's facts in the driver; the fourth is `/clear`, \ + which does it through `App::clear_conversation`", + ); + assert_eq!( + forgets_window, 4, + "the window belongs to the conversation, so every one of the four drops \ + it — a site that keeps it draws a whole page for a conversation the \ + operator has just discarded, beside a `ctx` field that is blank because \ + `forget_run` did clear that", + ); + + // And the fourth by name, since it is the one that was missing and a count + // alone would be satisfied by any four. + let clear = driver + .split("Action::Clear => {") + .nth(1) + .expect("the clear arm"); + let clear = &clear[..clear.find("Action::").unwrap_or(clear.len())]; + assert!( + clear.contains("seen.forget()"), + "`/clear` is the site `Seen::forget`'s own doc names first, and was the \ + one that did not call it", + ); +} diff --git a/tests/queue.rs b/tests/queue.rs index 71b9062..c421445 100644 --- a/tests/queue.rs +++ b/tests/queue.rs @@ -425,3 +425,68 @@ fn notice(app: &App) -> String { .map(|(_, text)| text.clone()) .unwrap_or_default() } + +/// A `/steer` that emptied the queue into a turn nothing read gives the lines +/// back. +/// +/// **The second-use shape, and the review found it behind a green suite.** +/// `/steer` REMOVES each line from the queue to send it, and the send cannot fail +/// while the inbox is alive — so a turn that reaches its last step before the next +/// boundary consumes the queue, delivers nothing, and used to leave the operator +/// with the work in a sentence. In the release whose headline is that a mid-turn +/// prompt is no longer destroyed. +#[test] +fn a_steer_that_was_never_delivered_goes_back_to_the_front_of_the_queue() { + let mut app = App::new(DARK, "a-model"); + start_turn(&mut app, "do the work"); + app.queue_prompt("first"); + app.queue_prompt("second"); + + // What `/steer` does: takes them out, one at a time, to hand over. + let sent: Vec = std::iter::from_fn(|| app.next_queued_prompt()).collect(); + assert_eq!(sent, ["first", "second"]); + assert!( + app.queued_prompts().is_empty(), + "the queue was spent sending" + ); + + // A line queued behind them while the turn was still going. + app.queue_prompt("third"); + + // The turn ends having read none of them. + let waiting = app.requeue_prompts(sent); + assert_eq!(waiting, 3); + assert_eq!( + app.queued_prompts(), + ["first", "second", "third"], + "the undelivered lines go back AHEAD of what was queued after them — \ + putting them behind would silently reorder the operator's work", + ); + assert_eq!( + app.status.queued_prompts, 3, + "and the status line counts them again", + ); +} + +/// The same lines, on a turn the operator stopped, are not put back. +/// +/// The driver decides this — a stopped turn drops the queue for the same reason it +/// drops the rest of it: one press of the stop key must not start another turn. +/// What this pins is that the two paths are different, so a later release cannot +/// collapse them into one. +#[test] +fn a_stopped_turn_drops_what_it_never_delivered() { + let mut app = App::new(DARK, "a-model"); + start_turn(&mut app, "do the work"); + app.queue_prompt("first"); + let sent: Vec = std::iter::from_fn(|| app.next_queued_prompt()).collect(); + assert_eq!(sent.len(), 1); + + assert_eq!( + app.forget_queued_prompts(), + 0, + "the queue is already empty — the stop path drops by NOT putting back", + ); + assert!(app.queued_prompts().is_empty()); + assert_eq!(app.status.queued_prompts, 0); +} diff --git a/tests/queue_edit.rs b/tests/queue_edit.rs index 0f9d5ed..6de1623 100644 --- a/tests/queue_edit.rs +++ b/tests/queue_edit.rs @@ -417,7 +417,13 @@ fn the_rows_scroll_to_keep_the_marked_line_on_screen() { "the numbers are the run order, not the row: a window that renumbered \ itself from one would say the marked line runs first. {deep:#?}", ); - assert!(deep[3].contains("6 more"), "{deep:#?}"); + // **One, not six, and this assertion is the defect it was written over.** The + // row sits UNDER the window, so it counts what is under it; with the window + // scrolled to 6, 7, 8 only line 9 is below, and the five that are above are + // announced by the numbering starting at `6.` rather than by a count below + // them. The first draft said six — everything unshown — which was right only + // while nothing was marked and the window began at the top. + assert!(deep[3].contains("1 more"), "{deep:#?}"); // The window never runs past the end. let last = queue::rows_for(&waiting, Some(8), 40, 4, &UNICODE); diff --git a/tests/queue_surface.rs b/tests/queue_surface.rs index 0587e7c..32be9d6 100644 --- a/tests/queue_surface.rs +++ b/tests/queue_surface.rs @@ -549,3 +549,87 @@ fn a_multi_line_prompt_is_drawn_as_one_row() { "{rows:#?}" ); } + +/// The `… N more` row counts what is BELOW the window, not what is outside it. +/// +/// **Right on the first draw and wrong from the first arrow**, which is why the +/// review found it and the suite did not. The row sits under the last line drawn, +/// so a count of everything unshown reads as a count of what follows — and once +/// the mark has scrolled the window down, most of what is unshown is above it. +/// What is above needs no row: the numbers are absolute, so a window opening at +/// `6.` says so by saying `6.`. +#[test] +fn the_count_under_the_window_is_what_is_under_the_window() { + let waiting: Vec = (1..=9).map(|n| format!("line {n}")).collect(); + + // Nothing marked: the window is at the top and everything unshown IS below. + let top = queue::rows_for(&waiting, None, 80, 4, &UNICODE); + assert!( + top.last().expect("a row").contains("6 more"), + "three drawn of nine leaves six below: {top:#?}", + ); + + // Marked on the eighth: the window has scrolled to 6, 7, 8 — and only line 9 + // is below it. The old arithmetic said six, five of which were behind. + let scrolled = queue::rows_for(&waiting, Some(7), 80, 4, &UNICODE); + assert!( + scrolled[0].contains("6. line 6"), + "the window follows the mark: {scrolled:#?}", + ); + assert!( + scrolled.last().expect("a row").contains("1 more"), + "one line is below the window, not six: {scrolled:#?}", + ); + + // And at the very bottom there is nothing below, so there is no row at all. + let bottom = queue::rows_for(&waiting, Some(8), 80, 4, &UNICODE); + assert!( + !bottom.iter().any(|row| row.contains("more")), + "a count of zero is a row that says nothing: {bottom:#?}", + ); +} + +/// The queue does not take the blank row when the fleet view is drawn in its place. +/// +/// The fleet is rendered INSTEAD of the queue, in the composer's own rect. Without +/// this the blank was released for a surface that then did not draw: the row +/// bought nothing and the fleet quietly grew by one. +#[test] +fn a_queue_behind_an_open_fleet_view_takes_no_row_from_the_layout() { + let mut app = with_queue(2); + let queued = rows_at(&app, TALL); + + app.toggle_fleet(); + let fleeted = rows_at(&app, TALL); + assert_eq!( + queued.len(), + fleeted.len(), + "the frame is the frame either way", + ); + assert!( + !fleeted.iter().any(|row| row.contains("queued prompt 0")), + "the fleet is drawn in the queue's place, so the queue is not drawn: {fleeted:#?}", + ); +} + +/// And it does not answer the keyboard from behind the fleet either. +/// +/// `Enter` at an empty prompt used to reach `Cursor::take` with the fleet open — +/// a line leaving the queue into a composer the fleet was covering, under a footer +/// saying it was being edited. A surface acting while invisible. +#[test] +fn a_queue_behind_an_open_fleet_view_answers_no_key() { + let mut app = with_queue(2); + app.toggle_fleet(); + + app.key(key(KeyCode::Enter)); + assert_eq!( + app.queued_prompts().len(), + 2, + "no line was taken out of the queue by a surface nobody can see", + ); + assert!( + app.composer.is_empty(), + "and nothing was put in the composer the fleet is covering", + ); +} diff --git a/tests/steer.rs b/tests/steer.rs index f4fb439..cb2d0af 100644 --- a/tests/steer.rs +++ b/tests/steer.rs @@ -181,8 +181,12 @@ fn f5_the_queue_is_sent_on_the_operators_word_and_not_before() { // And that one site reads the queue rather than a line of its own: F5 is // about a *queued* line reaching the running turn. + // The FIRST WORD, not the whole trimmed line. `/steer do the thing` used to + // miss this guard and fall to the mid-turn refusal below it, which told the + // operator to interrupt the turn first — the opposite of what the command + // does. let arm = text - .split_once("line.trim() == \"steer\"") + .split_once("line.split_whitespace().next() == Some(\"steer\")") .expect("the driver answers /steer while a turn is running") .1; let arm = arm