diff --git a/CHANGELOG.md b/CHANGELOG.md index a080003..bb7b6b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,99 @@ 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. + +**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. + +**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 +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 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 +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.** 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 Nobody edits `io.toml` by hand. diff --git a/Cargo.lock b/Cargo.lock index f184c99..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", @@ -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..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 @@ -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/README.md b/README.md index 5e4a767..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 @@ -336,6 +349,9 @@ 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 | +| `/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 | @@ -784,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 ec8f7f8..db475ad 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; @@ -187,6 +187,49 @@ 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, + /// 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, + /// 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 @@ -214,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. @@ -258,6 +302,9 @@ impl App { armed: None, contained: false, queued: Vec::new(), + prompts: Vec::new(), + queue_open: false, + queue: crate::queue::Cursor::default(), images: Vec::new(), turn_rows: 0, echo_rows: 0, @@ -600,6 +647,149 @@ 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()); + // **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 + // 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; + } + let next = self.prompts.remove(0); + self.status.queued_prompts = self.prompts.len(); + Some(next) + } + + /// 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 + } + + /// 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() + } + + /// 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 + /// 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 { + self.status.queued_prompts = 0; + 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 @@ -661,6 +851,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. @@ -944,13 +1141,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 @@ -1169,13 +1379,135 @@ 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; } _ => {} } } + // 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. + // + // 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. + // 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 + 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(), // The one key in this product that changes the operator's files on @@ -1262,6 +1594,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 +1607,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), }, }, @@ -1398,7 +1754,24 @@ 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. + // `&& !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 @@ -1476,7 +1849,55 @@ 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 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() { + spare.min(want) + } else { + 0 + }; + if queue_rows > 0 { + crate::queue::render( + &self.prompts, + self.queue.selection(self.prompts.len()), + 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/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/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 bfb0e9f..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. @@ -137,6 +137,24 @@ 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", + ), + ( + "/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", @@ -235,12 +253,18 @@ 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", "/compact", + ], + ), ( Group::Inspect, &[ "/help", "/status", + "/context", "/expand", "/fleet", "/mcp", @@ -655,13 +679,48 @@ 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 /// 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, + /// 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: + /// 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. @@ -683,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. @@ -841,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)), @@ -856,6 +915,25 @@ 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, + // **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 — @@ -883,6 +961,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/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/context.rs b/src/context.rs new file mode 100644 index 0000000..e130515 --- /dev/null +++ b/src/context.rs @@ -0,0 +1,530 @@ +//! 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 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 +/// 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/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 a8d2029..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 @@ -31,9 +35,11 @@ pub mod bridge; pub mod cli; pub mod clipboard; pub mod commands; +pub mod compact; pub mod complete; pub mod composer; pub mod configure; +pub mod context; pub mod contract; pub mod diff; pub mod edit; @@ -52,6 +58,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 a77f1f0..c1b9900 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}; @@ -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(); @@ -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)?; @@ -553,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); @@ -677,6 +689,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 +728,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 +1059,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); } @@ -1259,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() }; @@ -1373,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. // @@ -1420,6 +1442,55 @@ 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", + ), + // 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 + // 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 { @@ -1443,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) => { @@ -1508,43 +1590,86 @@ 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 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, + &seen, + 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 + // 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 +1797,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>, @@ -1688,17 +1818,28 @@ 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. 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<(), 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` - // 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 @@ -1716,38 +1857,58 @@ 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(); 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, // 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 @@ -1763,6 +1924,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, @@ -1779,12 +1956,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 @@ -1795,6 +1972,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); @@ -1816,7 +1998,18 @@ 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. + // + // 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)?; } Some(ask) = asks.recv() => { @@ -1855,15 +2048,21 @@ 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; } // **The second press, and it does not wait.** A // cancel is honoured at a step boundary, and a step @@ -1879,6 +2078,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 @@ -1911,6 +2111,141 @@ 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. + // **`/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`. + // 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 { + 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)); + } + } + // 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, + // 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 @@ -1937,6 +2272,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. _ => {} } } @@ -1989,13 +2338,95 @@ 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. + 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. commit_viewed(screen, app, &root, policy, &event)?; + commit_fold(app, store, &event, &mut folding); } 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. + // + // **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] {}", 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, + // 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 @@ -2010,7 +2441,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. @@ -2031,6 +2463,63 @@ 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() { + // **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, remaining); + } else { + app.status.note_context_from(store, event); + } +} + +/// 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; @@ -2046,7 +2535,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/provider.rs b/src/provider.rs index d260f8c..0c899db 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,129 @@ 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/src/queue.rs b/src/queue.rs new file mode 100644 index 0000000..5efcbd8 --- /dev/null +++ b/src/queue.rs @@ -0,0 +1,501 @@ +//! The queue surface: what is waiting behind the turn that is running. +//! +//! **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*. +//! +//! **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. 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, +//! 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. +//! +//! **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 +//! 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 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. + +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}; + +/// 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`, 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 +/// 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. +/// +/// **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_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 + // 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 { + // **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!( + "{}{}. {} {} {rest} more", + mark(at), + at + 1, + one_line(&waiting[at]), + 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 + }; + // The window ends on the marked line when the queue is longer than the rows, + // and never runs past the end of the list. With nothing marked it starts at + // the top, which is where it started before there was anything to mark. + let first = match selected { + Some(at) if waiting.len() > shown => { + at.saturating_sub(shown - 1).min(waiting.len() - shown) + } + _ => 0, + }; + let mut out: Vec = waiting + .iter() + .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!("{}{}. {}", mark(at), at + 1, one_line(prompt)), + width, + glyphs, + ) + }) + .collect(); + // **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!( + "{}{} {below} more", + if selected.is_some() { " " } else { "" }, + 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. 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, 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; + } + // 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/servers.rs b/src/servers.rs index 4b87751..9f922e3 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. @@ -92,10 +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, and the last failure if there was one. - seen: BTreeMap, Option)>, + /// What each server has been seen to do, by the id it was configured under. + seen: BTreeMap, } impl Observed { @@ -104,23 +137,33 @@ 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.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()); } } } @@ -138,9 +181,12 @@ impl Observed { pub fn of(&self, id: &str) -> Reached { match self.seen.get(id) { None => Reached::NotYet, - Some((tools, failure)) => match failure { + Some(seen) => match &seen.failed { Some(tool) => Reached::Failed { tool: tool.clone() }, - None => Reached::Answered { tools: tools.len() }, + None => Reached::Answered { + tools: seen.asked.len(), + offered: seen.offered, + }, }, } } @@ -195,7 +241,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/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 64e1d7b..e7dd019 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`]. + /// + /// **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. + /// + /// **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` 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. + /// `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. /// - /// 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. + /// 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. @@ -364,6 +437,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 +487,7 @@ impl Status { elapsed: Duration::ZERO, plain: false, budgets: Budgets::default(), + queued_prompts: 0, frame: 0, } } @@ -423,8 +518,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 @@ -465,6 +563,117 @@ 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. + /// + /// **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 + /// 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. + /// 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; + }; + 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 — @@ -568,6 +777,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 +836,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 +1087,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)); } @@ -1296,11 +1543,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}"), }, )); @@ -1308,10 +1578,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, @@ -1346,6 +1621,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/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/commands.rs b/tests/commands.rs index 485df66..035453b 100644 --- a/tests/commands.rs +++ b/tests/commands.rs @@ -42,6 +42,15 @@ 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", + // 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/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.rs b/tests/context.rs new file mode 100644 index 0000000..b310582 --- /dev/null +++ b/tests/context.rs @@ -0,0 +1,591 @@ +//! 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:?}", + ); + } +} + +/// 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", + ); +} + +/// 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/context_share.rs b/tests/context_share.rs new file mode 100644 index 0000000..07f4f08 --- /dev/null +++ b/tests/context_share.rs @@ -0,0 +1,552 @@ +//! 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"); + + // `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 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", + ); +} diff --git a/tests/contract.rs b/tests/contract.rs index ec1e3c2..1413cc4 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"), @@ -778,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 59fa5c6..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] @@ -144,11 +217,67 @@ 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"); + // 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 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 + // 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/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/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..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,11 +72,28 @@ 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"); - 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(); @@ -221,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 b4254ce..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 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 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(), 6); + 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(), 4); + assert_eq!(picker.matching(), 6); assert_eq!( marked(&picker), "contain", 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/queue.rs b/tests/queue.rs new file mode 100644 index 0000000..c421445 --- /dev/null +++ b/tests/queue.rs @@ -0,0 +1,492 @@ +//! 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() +} + +/// 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 new file mode 100644 index 0000000..6de1623 --- /dev/null +++ b/tests/queue_edit.rs @@ -0,0 +1,847 @@ +//! 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:#?}", + ); + // **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); + 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/queue_surface.rs b/tests/queue_surface.rs new file mode 100644 index 0000000..32be9d6 --- /dev/null +++ b/tests/queue_surface.rs @@ -0,0 +1,635 @@ +//! 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 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 +//! 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 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; + +// --------------------------------------------------------------------------- +// 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:#?}" + ); +} + +/// 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/servers.rs b/tests/servers.rs index 3b982d5..e9b1573 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; @@ -22,23 +23,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, } } @@ -83,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 { @@ -118,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" ); } @@ -137,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) + } ); } @@ -153,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" ); } @@ -186,6 +211,124 @@ 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 c4d5696..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:?}", @@ -797,6 +811,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 +823,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, ); @@ -1418,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(), @@ -1458,6 +1480,7 @@ fn reported() -> App { tool: None, ok: None, millis: None, + tools: Some(1), }), Duration::ZERO, ); @@ -1467,6 +1490,7 @@ fn reported() -> App { tool: Some("search".into()), ok: Some(true), millis: Some(12), + tools: None, }), Duration::ZERO, ); @@ -1585,7 +1609,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}", ); @@ -1707,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}"); @@ -1884,3 +1920,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..cb2d0af --- /dev/null +++ b/tests/steer.rs @@ -0,0 +1,341 @@ +//! 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", + ); + // 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()"), + "`/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", + ); +} + +/// 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. + // 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.split_whitespace().next() == Some(\"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", + ); +} 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 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.", ); } 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,